Skip to content

Commit 0877f04

Browse files
author
Frank de Lange
committed
Spodcast v0.5.0 which:
- fixes #13 (Cannot download episodes anymore) - uses _librespot-python_ interfaces instead of raw web API access (needed to fix #13) - can not yet determine decrypted file size for Spotify-hosted episodes (which used to work) so will only look at the file name to determine whether an episode has already been downloaded. To retry corrupted downloads just remove the partially downloaded file and try again.
1 parent c0f76f5 commit 0877f04

File tree

8 files changed

+142
-151
lines changed

8 files changed

+142
-151
lines changed

requirements.txt

-1
This file was deleted.

setup.cfg

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = spodcast
3-
version = 0.4.9
3+
version = 0.5.0
44
description = A caching Spotify podcast to RSS proxy.
55
long_description = file:README.md
66
long_description_content_type = text/markdown
@@ -20,7 +20,8 @@ platforms = any
2020
packages =
2121
spodcast
2222
install_requires =
23-
librespot >= 0.0.1
23+
librespot >= 0.0.5
24+
pybase62
2425
ffmpeg-python
2526
setuptools
2627
include_package_data =

spodcast/app.py

+13-7
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from itertools import islice
44
from librespot.audio.decoders import AudioQuality
5+
from librespot.metadata import ShowId, EpisodeId
56

6-
from spodcast.podcast import download_episode, get_show_episodes
7+
from spodcast.podcast import download_episode, get_episodes
78
from spodcast.utils import regex_input_for_urls
89
from spodcast.spodcast import Spodcast
910

@@ -15,10 +16,15 @@ def client(args) -> None:
1516

1617
if args.urls:
1718
for spotify_url in args.urls:
18-
episode_id, show_id = regex_input_for_urls(spotify_url)
19-
log.debug(f"episode_id {episode_id}. show_id {show_id}")
20-
if episode_id is not None:
19+
episode_id_str, show_id_str = regex_input_for_urls(spotify_url)
20+
log.debug(f"episode_id_str {episode_id_str}. show_id_str {show_id_str}")
21+
if episode_id_str is not None:
22+
episode_id = EpisodeId.from_base62(episode_id_str)
23+
log.debug("episode_id: %s", episode_id)
2124
download_episode(episode_id)
22-
elif show_id is not None:
23-
for episode in islice(get_show_episodes(show_id), Spodcast.CONFIG.get_max_episodes()):
24-
download_episode(episode)
25+
elif show_id_str is not None:
26+
show_id = ShowId.from_base62(show_id_str)
27+
log.debug("show_id: %s", show_id)
28+
for episode_id in islice(get_episodes(show_id), Spodcast.CONFIG.get_max_episodes()):
29+
log.debug("episode_id: %s", episode_id)
30+
download_episode(episode_id)

spodcast/const.py

+3-17
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,8 @@
1-
ERROR = 'error'
2-
ITEMS = 'items'
3-
NAME = 'name'
4-
DESCRIPTION = "description"
5-
ID = 'id'
6-
URL = 'url'
7-
URI = 'uri'
8-
EXTERNAL_URLS = 'external_urls'
9-
SPOTIFY = 'spotify'
10-
RELEASE_DATE = 'release_date'
11-
IMAGES = 'images'
1+
TYPE = 'type'
122
LIMIT = 'limit'
133
OFFSET = 'offset'
144
CREDENTIALS_PREFIX = 'spodcast-cred'
15-
AUTHORIZATION = 'Authorization'
16-
DURATION_MS = 'duration_ms'
17-
SHOW = 'show'
18-
TYPE = 'type'
195
USER_READ_EMAIL = 'user-read-email'
20-
PLAYLIST_READ_PRIVATE = 'playlist-read-private'
21-
USER_LIBRARY_READ = 'user-library-read'
226
FILE_EXISTS = -1
7+
OPEN_SPOTIFY_URL = 'open.spotify.com'
8+
IMAGE_CDN = lambda image_id_hex: f'https://i.scdn.co/image/{image_id_hex}'

spodcast/podcast.py

+118-115
Original file line numberDiff line numberDiff line change
@@ -6,68 +6,61 @@
66
from html import escape
77
import urllib.parse
88

9-
from librespot.metadata import EpisodeId
10-
9+
import base62
10+
from base62 import CHARSET_INVERTED
1111
import ffmpeg
1212

13-
from spodcast.const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS, DESCRIPTION, RELEASE_DATE, URI, URL, EXTERNAL_URLS, IMAGES, SPOTIFY, FILE_EXISTS
13+
from librespot import util
14+
from librespot.metadata import ShowId, EpisodeId
15+
from librespot.core import ApiClient
16+
17+
from spodcast.const import FILE_EXISTS, IMAGE_CDN
1418
from spodcast.feedgenerator import RSS_FEED_CODE, RSS_FEED_FILE_NAME, RSS_FEED_SHOW_INDEX, RSS_FEED_INFO_EXTENSION, RSS_FEED_SHOW_IMAGE, RSS_FEED_VERSION, get_index_version
15-
from spodcast.spotapi import EPISODE_INFO_URL, SHOWS_URL, EPISODE_DOWNLOAD_URL, ANON_PODCAST_DOMAIN
16-
from spodcast.utils import clean_filename
19+
from spodcast.utils import clean_filename, uri_to_url
1720
from spodcast.spodcast import Spodcast
1821

1922
log = logging.getLogger(__name__)
2023

21-
def get_info(episode_id_str, target="episode"):
22-
log.info("Fetching episode information...")
23-
(raw, info) = Spodcast.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}')
24-
if not info:
25-
log.error('INVALID EPISODE ID')
26-
27-
log.debug("episode info: %s", info)
28-
29-
if ERROR in info:
30-
return None, None
3124

32-
if target == "episode":
25+
def hex_to_spotify_id(hex_id):
26+
return base62.encodebytes(util.hex_to_bytes(hex_id), CHARSET_INVERTED)
3327

34-
podcast_name = info[SHOW][NAME]
35-
episode_name = info[NAME]
36-
duration_ms = info[DURATION_MS]
37-
description = info[DESCRIPTION]
38-
release_date = info[RELEASE_DATE]
39-
uri = info[URI]
4028

41-
return podcast_name, duration_ms, episode_name, description, release_date, uri
29+
def get_show_info(show_id_hex):
30+
log.info("Fetching show information...")
31+
show_id = ShowId.from_hex(show_id_hex)
32+
uri = f'spotify:show:{hex_to_spotify_id(show_id_hex)}'
33+
info = Spodcast.SESSION.api().get_metadata_4_show(show_id)
34+
link = uri_to_url(uri)
35+
description = info.description
36+
image = IMAGE_CDN(util.bytes_to_hex(info.cover_image.image[1].file_id))
4237

43-
elif target == "show":
44-
podcast_name = info[SHOW][NAME]
45-
link = info[SHOW][EXTERNAL_URLS][SPOTIFY]
46-
description = info[SHOW][DESCRIPTION]
47-
image = info[SHOW][IMAGES][0][URL]
38+
return link, description, image
4839

49-
return podcast_name, link, description, image
5040

41+
def get_episode_info(episode_id_hex):
42+
log.info("Fetching episode information...")
43+
episode_id = EpisodeId.from_hex(episode_id_hex)
44+
uri = f'spotify:episode:{hex_to_spotify_id(episode_id_hex)}'
45+
info = Spodcast.SESSION.api().get_metadata_4_episode(episode_id)
46+
podcast_name = info.show.name
47+
podcast_id = util.bytes_to_hex(info.show.gid)
48+
episode_name = info.name
49+
duration_ms = info.duration
50+
description = info.description
51+
external_url = info.external_url if info.external_url else None
52+
pt = info.publish_time
53+
release_date = f'{pt.year}-{pt.month}-{pt.day}T{pt.hour}:{pt.minute}:00Z'
5154

52-
def get_show_episodes(show_id_str) -> list:
53-
episodes = []
54-
offset = 0
55-
limit = 50
55+
return podcast_name, podcast_id, duration_ms, episode_name, description, release_date, uri, external_url
5656

57-
log.info("Fetching episodes...")
58-
while True:
59-
resp = Spodcast.invoke_url_with_params(
60-
f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset)
61-
offset += limit
62-
for episode in resp[ITEMS]:
63-
episodes.append([episode[ID], episode[RELEASE_DATE]])
64-
if len(resp[ITEMS]) < limit:
65-
break
6657

67-
# some shows list episodes in the wrong order so reverse sort them by release date
68-
episodes.sort(key=lambda x: datetime.strptime(x[1], "%Y-%m-%d"), reverse=True)
58+
def get_episodes(show_id):
59+
info = Spodcast.SESSION.api().get_metadata_4_show(show_id)
60+
episodes = info.episode
61+
episodes.sort(key = lambda x: datetime.strptime(f'{x.publish_time.year}-{x.publish_time.month}-{x.publish_time.day}T{x.publish_time.hour}:{x.publish_time.minute}:00Z', "%Y-%m-%dT%H:%M:%SZ"), reverse=True)
6962

70-
return [episode[0] for episode in episodes]
63+
return [util.bytes_to_hex(episode.gid) for episode in episodes]
7164

7265

7366
def download_file(url, filepath):
@@ -101,14 +94,24 @@ def download_file(url, filepath):
10194

10295
return filepath, os.path.getsize(filepath), mimetype
10396

97+
10498
def download_stream(stream, filepath):
10599
size = stream.input_stream.size
100+
106101
mp3_filepath = os.path.splitext(filepath)[0] + ".mp3"
107102
mimetype = "audio/ogg"
108103

109104
if (
110-
((os.path.isfile(filepath)
111-
and abs(size - os.path.getsize(filepath)) < 1000)
105+
# "FILE SIZE CHECK TEMPORARILY OUT OF ORDER"
106+
# Need to find a way to get decrypted content size
107+
# from Spotify to enable file size checks, for now
108+
# this only checks for the presence of a file with
109+
# the same name. To recover from failed downloads
110+
# simply remove incomplete files
111+
#
112+
#((os.path.isfile(filepath)
113+
#and abs(size - os.path.getsize(filepath)) < 1000)
114+
(os.path.isfile(filepath)
112115
or (Spodcast.CONFIG.get_transcode()
113116
and os.path.isfile(mp3_filepath)))
114117
and Spodcast.CONFIG.get_skip_existing_files()
@@ -145,75 +148,75 @@ def download_stream(stream, filepath):
145148

146149

147150
def download_episode(episode_id) -> None:
148-
podcast_name, duration_ms, episode_name, description, release_date, uri = get_info(episode_id, "episode")
149-
150-
if podcast_name is None:
151-
log.warning('Skipping episode (podcast NOT FOUND)')
152-
elif episode_name is None:
153-
log.warning('Skipping episode (episode NOT FOUND)')
154-
else:
155-
filename = clean_filename(podcast_name + ' - ' + episode_name)
156-
log.debug(Spodcast.invoke_url(EPISODE_DOWNLOAD_URL(episode_id)))
157-
download_url = Spodcast.invoke_url(EPISODE_DOWNLOAD_URL(episode_id))[1]["data"]["episode"]["audio"]["items"][-1]["url"]
158-
log.debug(f"download_url: {download_url}")
159-
show_directory = os.path.realpath(os.path.join(Spodcast.CONFIG.get_root_path(), clean_filename(podcast_name) + '/'))
160-
os.makedirs(show_directory, exist_ok=True)
161-
162-
if ANON_PODCAST_DOMAIN in download_url:
163-
episode_stream_id = EpisodeId.from_base62(episode_id)
164-
stream = Spodcast.get_content_stream(episode_stream_id, Spodcast.DOWNLOAD_QUALITY)
165-
basename = f"{filename}.ogg"
166-
filepath = os.path.join(show_directory, basename)
167-
path, size, mimetype = download_stream(stream, filepath)
168-
basename = os.path.basename(path) # may have changed due to transcoding
169-
else:
170-
basename=f"{filename}.mp3"
171-
filepath = os.path.join(show_directory, basename)
172-
path, size, mimetype = download_file(download_url, filepath)
151+
try:
152+
podcast_name, podcast_id, duration_ms, episode_name, description, release_date, uri, download_url = get_episode_info(episode_id)
173153

174-
if size == FILE_EXISTS:
175-
log.info(f"Skipped {podcast_name}: {episode_name}")
154+
if podcast_name is None:
155+
log.warning('Skipping episode (podcast NOT FOUND)')
156+
elif episode_name is None:
157+
log.warning('Skipping episode (episode NOT FOUND)')
176158
else:
177-
log.warning(f"Downloaded {podcast_name}: {episode_name}")
159+
filename = clean_filename(podcast_name + ' - ' + episode_name)
160+
show_directory = os.path.realpath(os.path.join(Spodcast.CONFIG.get_root_path(), clean_filename(podcast_name) + '/'))
161+
os.makedirs(show_directory, exist_ok=True)
162+
163+
if download_url is None:
164+
episode_stream_id = EpisodeId.from_hex(episode_id)
165+
stream = Spodcast.get_content_stream(episode_stream_id, Spodcast.DOWNLOAD_QUALITY)
166+
basename = f"{filename}.ogg"
167+
filepath = os.path.join(show_directory, basename)
168+
path, size, mimetype = download_stream(stream, filepath)
169+
basename = os.path.basename(path) # may have changed due to transcoding
170+
else:
171+
basename=f"{filename}.mp3"
172+
filepath = os.path.join(show_directory, basename)
173+
path, size, mimetype = download_file(download_url, filepath)
174+
175+
if size == FILE_EXISTS:
176+
log.info(f"Skipped {podcast_name}: {episode_name}")
177+
else:
178+
log.warning(f"Downloaded {podcast_name}: {episode_name}")
179+
180+
if Spodcast.CONFIG.get_rss_feed():
181+
episode_info = {
182+
"mimetype": mimetype,
183+
"medium": "audio",
184+
"duration": int(duration_ms/1000),
185+
"date": time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.strptime(release_date, "%Y-%m-%dT%H:%M:%SZ")),
186+
"title": escape(episode_name), "guid": uri, "description": escape(description),
187+
"filename": urllib.parse.quote(basename),
188+
"size": int(size) }
189+
info_file = open(os.path.join(show_directory, f"{basename}.{RSS_FEED_INFO_EXTENSION}"), "w")
190+
info_file.write(json.dumps(episode_info))
191+
info_file.close()
178192

179193
if Spodcast.CONFIG.get_rss_feed():
180-
episode_info = {
181-
"mimetype": mimetype,
182-
"medium": "audio",
183-
"duration": int(duration_ms/1000),
184-
"date": time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.strptime(release_date, "%Y-%m-%d")),
185-
"title": escape(episode_name), "guid": uri, "description": escape(description),
186-
"filename": urllib.parse.quote(basename),
187-
"size": int(size) }
188-
info_file = open(os.path.join(show_directory, f"{basename}.{RSS_FEED_INFO_EXTENSION}"), "w")
189-
info_file.write(json.dumps(episode_info))
190-
info_file.close()
191-
192-
if Spodcast.CONFIG.get_rss_feed():
193-
show_index_file_name = os.path.join(show_directory, f"{RSS_FEED_SHOW_INDEX}.{RSS_FEED_INFO_EXTENSION}")
194-
if not os.path.isfile(show_index_file_name) or int(get_index_version(show_index_file_name)) < Spodcast.CONFIG.get_version_int():
195-
podcast_name, link, description, image = get_info(episode_id, "show")
196-
show_info = {}
197-
if os.path.isfile(show_index_file_name):
198-
with open(show_index_file_name, encoding='utf-8') as file:
199-
show_info = json.load(file)
200-
file.close()
201-
show_info["version"] = str(RSS_FEED_VERSION + Spodcast.CONFIG.get_version_str())
202-
show_info["title"] = escape(podcast_name)
203-
show_info["link"] = link
204-
show_info["description"] = escape(description)
205-
show_info["image"] = RSS_FEED_SHOW_IMAGE
206-
show_index_file = open(show_index_file_name, "w")
207-
show_index_file.write(json.dumps(show_info))
208-
show_index_file.close()
209-
210-
show_image_name = os.path.join(show_directory, f"{RSS_FEED_SHOW_IMAGE}")
211-
if not os.path.isfile(show_image_name):
212-
download_file(image, show_image_name)
213-
214-
rss_file_name = os.path.join(show_directory, RSS_FEED_FILE_NAME)
215-
if not os.path.isfile(rss_file_name) or int(get_index_version(rss_file_name)) < Spodcast.CONFIG.get_version_int():
216-
rss_file = open(rss_file_name, "w")
217-
rss_file.write(RSS_FEED_CODE(Spodcast.CONFIG.get_version_str()))
218-
rss_file.close()
219-
194+
show_index_file_name = os.path.join(show_directory, f"{RSS_FEED_SHOW_INDEX}.{RSS_FEED_INFO_EXTENSION}")
195+
if not os.path.isfile(show_index_file_name) or int(get_index_version(show_index_file_name)) < Spodcast.CONFIG.get_version_int():
196+
podcast_link, podcast_description, podcast_image = get_show_info(podcast_id)
197+
show_info = {}
198+
if os.path.isfile(show_index_file_name):
199+
with open(show_index_file_name, encoding='utf-8') as file:
200+
show_info = json.load(file)
201+
file.close()
202+
show_info["version"] = str(RSS_FEED_VERSION + Spodcast.CONFIG.get_version_str())
203+
show_info["title"] = escape(podcast_name)
204+
show_info["link"] = podcast_link
205+
show_info["description"] = escape(podcast_description)
206+
show_info["image"] = RSS_FEED_SHOW_IMAGE
207+
show_index_file = open(show_index_file_name, "w")
208+
show_index_file.write(json.dumps(show_info))
209+
show_index_file.close()
210+
211+
show_image_name = os.path.join(show_directory, f"{RSS_FEED_SHOW_IMAGE}")
212+
if not os.path.isfile(show_image_name):
213+
download_file(podcast_image, show_image_name)
214+
215+
rss_file_name = os.path.join(show_directory, RSS_FEED_FILE_NAME)
216+
if not os.path.isfile(rss_file_name) or int(get_index_version(rss_file_name)) < Spodcast.CONFIG.get_version_int():
217+
rss_file = open(rss_file_name, "w")
218+
rss_file.write(RSS_FEED_CODE(Spodcast.CONFIG.get_version_str()))
219+
rss_file.close()
220+
221+
except ApiClient.StatusCodeException as status:
222+
log.warning("episode %s, StatusCodeException: %s", episode_id, status)

spodcast/spodcast.py

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def invoke_url_with_params(cls, url, limit, offset, **kwargs):
137137
@classmethod
138138
def invoke_url(cls, url, tryCount=0):
139139
headers = cls.get_auth_header()
140+
Spodcast.LOG.debug(headers)
140141
response = requests.get(url, headers=headers)
141142
responsetext = response.text
142143
responsejson = response.json()

spodcast/spotapi.py

-9
This file was deleted.

spodcast/utils.py

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import List, Tuple
66

77
from spodcast.spodcast import Spodcast
8+
from spodcast.const import OPEN_SPOTIFY_URL
89

910
valid_filename_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
1011

@@ -47,3 +48,6 @@ def clean_filename(filename, whitelist=valid_filename_chars, replace=' '):
4748
cleaned_filename = ''.join(c for c in cleaned_filename if c in whitelist)
4849
return cleaned_filename
4950

51+
def uri_to_url(spotify_id):
52+
(spotify,sp_type,sp_id) = spotify_id.split(':')
53+
return f'https://{OPEN_SPOTIFY_URL}/{sp_type}/{sp_id}'

0 commit comments

Comments
 (0)