Merge remote-tracking branch 'upstream/main'

This commit is contained in:
dabreadman 2021-10-24 18:06:52 +00:00
commit 2014cfa160
11 changed files with 133 additions and 43 deletions

22
.github/workflows/pylint.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Pylint
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pylint
- name: Analysing the code with pylint
run: |
pylint `ls -R|grep .py$|xargs`

40
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,40 @@
# Introduction
### Thank you for contributing
Without people like you this project wouldn't be anywhere near as polished and feature-rich as it is now.
### Guidelines
Following these guidelines helps show that you respect the the time and effort spent by the developers and your fellow contributors making this project.
### What we are looking for
ZSpotify is a community-driven project. There are many different ways to contribute. From providing tutorials and examples to help new users, reporting bugs, requesting new features, writing new code that can be added to the project, or even writing documentation.
### What we aren't looking for
Please don't use the issues section to request help installing or setting up the project. It should be reserved for bugs when running the code, and feature requqests. Instead use the support channel in either our Discord or Matrix server.
# Ground rules
### Expectations
* Ensure all code is linted with pylint before pushing.
* Ensure all code passes the [testing criteria](#testing-criteria).
* If you're planning on contributing a new feature, join the Discord or Matrix and discuss it with the Dev Team.
* Please don't commit multiple new features at once.
* Follow the [Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/)
# Your first contribution
Unsure where to start? Have a look for any issues tagged "good first issue". They should be minor bugs that only require a few lines to fix.
Here are a couple of friendly tutorials on making pull requests: http://makeapullrequest.com/ and http://www.firsttimersonly.com/
# Code review process
The dev team looks at Pull Requests around once per day. After feedback has been given we expect responses within one week. After a week we may close the pull request if it isn't showing any activity.
> ZSpotify updates very frequently, often multiple times per day. If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge.
# Community
Come and chat with us on Discord or Matrix. Devs try to respond to mentions at least once per day.

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python:3.9-alpine as base
RUN apk --update add git ffmpeg
FROM base as builder
RUN mkdir /install
WORKDIR /install
COPY requirements.txt /requirements.txt
RUN apk add gcc libc-dev zlib zlib-dev jpeg-dev \
&& pip install --prefix="/install" -r /requirements.txt
FROM base
COPY --from=builder /install /usr/local
COPY src /app
COPY zs_config.json /
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/python", "app.py"]

View File

@ -14,7 +14,7 @@ Requirements:
Binaries
- Python 3.8 or greater
- Python 3.9 or greater
- ffmpeg*
- Git**
@ -29,9 +29,9 @@ Python packages:
\*\*Git can be installed via apt for Debian-based distros or by downloading the binaries from [git-scm.com](https://git-scm.com/download/win) for Windows.
```
Command line usage:
python zspotify.py Loads search prompt to find then download a specific track, album or playlist
python zspotify.py <track/album/playlist/episode url> Downloads the track, album, playlist or podcast episode specified as a command line argument
python zspotify.py <artist url> Downloads all albums by specified artist
python app.py Loads search prompt to find then download a specific track, album or playlist
python app.py <track/album/playlist/episode url> Downloads the track, album, playlist or podcast episode specified as a command line argument
python app.py <artist url> Downloads all albums by specified artist
Extra command line options:
-p, --playlist Downloads a saved playlist from your account
@ -43,8 +43,7 @@ Options that can be configured in zs_config.json:
SKIP_EXISTING_FILES Set this to false if you want ZSpotify to overwrite files with the same name rather than skipping the song
MUSIC_FORMAT Set this to "ogg" if you would rather that format audio over "mp3"
RAW_AUDIO_AS_IS Set this to true to only stream the audio to a file and do no re-encoding or post processing
MUSIC_FORMAT Can be "mp3" or "ogg", mp3 is required for track metadata however ogg is slightly higer quality as it is not trsnacoded.
FORCE_PREMIUM Set this to true if ZSpotify isn't automatically detecting that you are using a premium account
@ -60,9 +59,20 @@ This isn't to say _you_ won't get banned as it is technically against Spotify's
If you see this, don't worry! Just try logging back in. If you see the incorrect username or password error, reset your password and you should be able to log back in and continue using Spotify.
### Contributing
Please be sure to lint your code with pylint before issuing a pull-request, thanks!
Please refer to CONTRIBUTING.md
## **Changelog:**
**v2.2 (24 Oct 2021):**
- Added basic support for downloading an entire podcast series.
- Split code into multiple files for easier maintenance.
- Changed initial launch script to app.py
- Simplified audio formats.
- Added prebuild exe for Windows users.
- Added Docker file.
- Added CONTRIBUTING.md.
- Fixed artist names getting cutoff in metadata.
- Removed data sanitization of metadata tags.
**v2.1 (23 Oct 2021):**
- Moved configuration from hard-coded values to separate zs_config.json file.
- Add subfolders for each disc.

View File

@ -28,7 +28,7 @@ def get_album_tracks(album_id):
def get_album_name(album_id):
""" Returns album name """
resp = ZSpotify.invoke_url(f'{ALBUM_URL}/{album_id}')
return sanitize_data(resp[ARTISTS][0][NAME]), sanitize_data(resp[NAME])
return resp[ARTISTS][0][NAME], sanitize_data(resp[NAME])
def get_artist_albums(artist_id):

View File

@ -84,8 +84,6 @@ SKIP_EXISTING_FILES = 'SKIP_EXISTING_FILES'
DOWNLOAD_FORMAT = 'DOWNLOAD_FORMAT'
RAW_AUDIO_AS_IS = 'RAW_AUDIO_AS_IS'
FORCE_PREMIUM = 'FORCE_PREMIUM'
ANTI_BAN_WAIT_TIME = 'ANTI_BAN_WAIT_TIME'

View File

@ -1,3 +1,4 @@
import os
from typing import Optional
from librespot.audio.decoders import VorbisOnlyAudioQuality
@ -8,6 +9,7 @@ from const import NAME, ERROR, SHOW, ITEMS, ID, ROOT_PODCAST_PATH, CHUNK_SIZE
from utils import sanitize_data, create_download_directory, MusicFormat
from zspotify import ZSpotify
EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes'
SHOWS_URL = 'https://api.spotify.com/v1/shows'
@ -21,11 +23,16 @@ def get_episode_info(episode_id_str) -> tuple[Optional[str], Optional[str]]:
def get_show_episodes(show_id_str) -> list:
episodes = []
offset = 0
limit = 50
resp = ZSpotify.invoke_url(f'{SHOWS_URL}/{show_id_str}/episodes')
for episode in resp[ITEMS]:
episodes.append(episode[ID])
while True:
resp = ZSpotify.invoke_url_with_params(f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset)
offset += limit
for episode in resp[ITEMS]:
episodes.append(episode[ID])
if len(resp[ITEMS]) < limit:
break
return episodes
@ -43,10 +50,11 @@ def download_episode(episode_id) -> None:
episode_id = EpisodeId.from_base62(episode_id)
stream = ZSpotify.get_content_stream(episode_id, ZSpotify.DOWNLOAD_QUALITY)
create_download_directory(ZSpotify.get_config(ROOT_PODCAST_PATH) + extra_paths)
download_directory = os.path.dirname(__file__) + ZSpotify.get_config(ROOT_PODCAST_PATH) + extra_paths
create_download_directory(download_directory)
total_size = stream.input_stream.size
with open(ZSpotify.get_config(ROOT_PODCAST_PATH) + extra_paths + filename + MusicFormat.WAV.value,
with open(download_directory + filename + MusicFormat.OGG.value,
'wb') as file, tqdm(
desc=filename,
total=total_size,
@ -59,6 +67,4 @@ def download_episode(episode_id) -> None:
stream.input_stream.stream().read(ZSpotify.get_config(CHUNK_SIZE))))
# convert_audio_format(ROOT_PODCAST_PATH +
# extra_paths + filename + '.wav')
# related functions that do stuff with the spotify API
# extra_paths + filename + '.ogg')

View File

@ -9,7 +9,7 @@ from tqdm import tqdm
from const import TRACKS, ALBUM, NAME, ITEMS, DISC_NUMBER, TRACK_NUMBER, IS_PLAYABLE, ARTISTS, IMAGES, URL, \
RELEASE_DATE, ID, TRACKS_URL, SAVED_TRACKS_URL, SPLIT_ALBUM_DISCS, ROOT_PATH, DOWNLOAD_FORMAT, CHUNK_SIZE, \
SKIP_EXISTING_FILES, RAW_AUDIO_AS_IS, ANTI_BAN_WAIT_TIME, OVERRIDE_AUTO_WAIT
SKIP_EXISTING_FILES, ANTI_BAN_WAIT_TIME, OVERRIDE_AUTO_WAIT
from utils import sanitize_data, set_audio_tags, set_music_thumbnail, create_download_directory, \
MusicFormat
from zspotify import ZSpotify
@ -37,9 +37,9 @@ def get_song_info(song_id) -> tuple[list[str], str, str, Any, Any, Any, Any, Any
artists = []
for data in info[TRACKS][0][ARTISTS]:
artists.append(data[NAME])
album_name = info[TRACKS][0][ALBUM][NAME]
name = info[TRACKS][0][NAME]
artists.append(sanitize_data(data[NAME]))
album_name = sanitize_data(info[TRACKS][0][ALBUM][NAME])
name = sanitize_data(info[TRACKS][0][NAME])
image_url = info[TRACKS][0][ALBUM][IMAGES][0][URL]
release_year = info[TRACKS][0][ALBUM][RELEASE_DATE].split('-')[0]
disc_number = info[TRACKS][0][DISC_NUMBER]
@ -53,21 +53,22 @@ def get_song_info(song_id) -> tuple[list[str], str, str, Any, Any, Any, Any, Any
# noinspection PyBroadException
def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='', disable_progressbar=False) -> None:
""" Downloads raw song audio from Spotify """
download_directory = os.path.join(os.path.dirname(__file__), ZSpotify.get_config(ROOT_PATH), extra_paths)
try:
(artists, album_name, name, image_url, release_year, disc_number,
track_number, scraped_song_id, is_playable) = get_song_info(track_id)
song_name = sanitize_data(artists[0]) + ' - ' + sanitize_data(name)
song_name = artists[0] + ' - ' + name
if prefix:
song_name = f'{prefix_value.zfill(2)} - {song_name}' if prefix_value.isdigit(
) else f'{prefix_value} - {song_name}'
if ZSpotify.get_config(SPLIT_ALBUM_DISCS):
filename = os.path.join(ZSpotify.get_config(ROOT_PATH), extra_paths, 'Disc ' + str(
disc_number) + '/' + song_name + '.' + ZSpotify.get_config(DOWNLOAD_FORMAT))
filename = os.path.join(download_directory, f'Disc {disc_number}',
f'{song_name}.{ZSpotify.get_config(DOWNLOAD_FORMAT)}')
else:
filename = os.path.join(ZSpotify.get_config(ROOT_PATH), extra_paths,
song_name + '.' + ZSpotify.get_config(DOWNLOAD_FORMAT))
filename = os.path.join(download_directory,
f'{song_name}.{ZSpotify.get_config(DOWNLOAD_FORMAT)}')
except Exception:
print('### SKIPPING SONG - FAILED TO QUERY METADATA ###')
else:
@ -84,7 +85,7 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='',
track_id = scraped_song_id
track_id = TrackId.from_base62(track_id)
stream = ZSpotify.get_content_stream(track_id, ZSpotify.DOWNLOAD_QUALITY)
create_download_directory(ZSpotify.get_config(ROOT_PATH) + extra_paths)
create_download_directory(download_directory)
total_size = stream.input_stream.size
with open(filename, 'wb') as file, tqdm(
@ -99,7 +100,7 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='',
p_bar.update(file.write(
stream.input_stream.stream().read(ZSpotify.get_config(CHUNK_SIZE))))
if not ZSpotify.get_config(RAW_AUDIO_AS_IS):
if ZSpotify.get_config(DOWNLOAD_FORMAT) == 'mp3':
convert_audio_format(filename)
set_audio_tags(filename, artists, name, album_name,
release_year, disc_number, track_number)
@ -107,15 +108,16 @@ def download_track(track_id: str, extra_paths='', prefix=False, prefix_value='',
if not ZSpotify.get_config(OVERRIDE_AUTO_WAIT):
time.sleep(ZSpotify.get_config(ANTI_BAN_WAIT_TIME))
except Exception:
except Exception as e:
print('### SKIPPING:', song_name,
'(GENERAL DOWNLOAD ERROR) ###')
print(e)
if os.path.exists(filename):
os.remove(filename)
def convert_audio_format(filename) -> None:
""" Converts raw audio into playable mp3 or ogg vorbis """
""" Converts raw audio into playable mp3 """
# print('### CONVERTING TO ' + MUSIC_FORMAT.upper() + ' ###')
raw_audio = AudioSegment.from_file(filename, format=MusicFormat.OGG.value,
frame_rate=44100, channels=2, sample_width=2)

View File

@ -14,8 +14,7 @@ from const import SANITIZE, ARTIST, TRACKTITLE, ALBUM, YEAR, DISCNUMBER, TRACKNU
class MusicFormat(str, Enum):
MP3 = 'mp3',
OGG = 'ogg',
WAV = 'wav'
def create_download_directory(download_path: str) -> None:
os.makedirs(download_path, exist_ok=True)

View File

@ -17,7 +17,7 @@ from librespot.audio.decoders import VorbisOnlyAudioQuality
from librespot.core import Session
from const import CREDENTIALS_JSON, TYPE, \
PREMIUM, USER_READ_EMAIL, AUTHORIZATION, OFFSET, LIMIT, CONFIG_FILE_PATH, FORCE_PREMIUM, RAW_AUDIO_AS_IS, \
PREMIUM, USER_READ_EMAIL, AUTHORIZATION, OFFSET, LIMIT, CONFIG_FILE_PATH, FORCE_PREMIUM, \
PLAYLIST_READ_PRIVATE
from utils import MusicFormat
@ -29,7 +29,6 @@ class ZSpotify:
def __init__(self):
ZSpotify.load_config()
ZSpotify.check_raw()
ZSpotify.login()
@classmethod
@ -55,18 +54,14 @@ class ZSpotify:
@classmethod
def load_config(cls) -> None:
with open(CONFIG_FILE_PATH, encoding='utf-8') as config_file:
app_dir = os.path.dirname(__file__)
with open(os.path.join(app_dir, CONFIG_FILE_PATH), encoding='utf-8') as config_file:
cls.CONFIG = json.load(config_file)
@classmethod
def get_config(cls, key) -> Any:
return cls.CONFIG.get(key)
@classmethod
def check_raw(cls) -> None:
if cls.get_config(RAW_AUDIO_AS_IS):
cls.DOWNLOAD_FORMAT = MusicFormat.WAV
@classmethod
def get_content_stream(cls, content_id, quality):
return cls.SESSION.content_feeder().load(content_id, VorbisOnlyAudioQuality(quality), False, None)

View File

@ -3,7 +3,6 @@
"ROOT_PODCAST_PATH": "../ZSpotify Podcasts/",
"SKIP_EXISTING_FILES": true,
"DOWNLOAD_FORMAT": "mp3",
"RAW_AUDIO_AS_IS": false,
"FORCE_PREMIUM": false,
"ANTI_BAN_WAIT_TIME": 1,
"OVERRIDE_AUTO_WAIT": false,