|
6 | 6 | from html import escape
|
7 | 7 | import urllib.parse
|
8 | 8 |
|
9 |
| -from librespot.metadata import EpisodeId |
10 |
| - |
| 9 | +import base62 |
| 10 | +from base62 import CHARSET_INVERTED |
11 | 11 | import ffmpeg
|
12 | 12 |
|
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 |
14 | 18 | 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 |
17 | 20 | from spodcast.spodcast import Spodcast
|
18 | 21 |
|
19 | 22 | log = logging.getLogger(__name__)
|
20 | 23 |
|
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 |
31 | 24 |
|
32 |
| - if target == "episode": |
| 25 | +def hex_to_spotify_id(hex_id): |
| 26 | + return base62.encodebytes(util.hex_to_bytes(hex_id), CHARSET_INVERTED) |
33 | 27 |
|
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] |
40 | 28 |
|
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)) |
42 | 37 |
|
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 |
48 | 39 |
|
49 |
| - return podcast_name, link, description, image |
50 | 40 |
|
| 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' |
51 | 54 |
|
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 |
56 | 56 |
|
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 |
66 | 57 |
|
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) |
69 | 62 |
|
70 |
| - return [episode[0] for episode in episodes] |
| 63 | + return [util.bytes_to_hex(episode.gid) for episode in episodes] |
71 | 64 |
|
72 | 65 |
|
73 | 66 | def download_file(url, filepath):
|
@@ -101,14 +94,24 @@ def download_file(url, filepath):
|
101 | 94 |
|
102 | 95 | return filepath, os.path.getsize(filepath), mimetype
|
103 | 96 |
|
| 97 | + |
104 | 98 | def download_stream(stream, filepath):
|
105 | 99 | size = stream.input_stream.size
|
| 100 | + |
106 | 101 | mp3_filepath = os.path.splitext(filepath)[0] + ".mp3"
|
107 | 102 | mimetype = "audio/ogg"
|
108 | 103 |
|
109 | 104 | 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) |
112 | 115 | or (Spodcast.CONFIG.get_transcode()
|
113 | 116 | and os.path.isfile(mp3_filepath)))
|
114 | 117 | and Spodcast.CONFIG.get_skip_existing_files()
|
@@ -145,75 +148,75 @@ def download_stream(stream, filepath):
|
145 | 148 |
|
146 | 149 |
|
147 | 150 | 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) |
173 | 153 |
|
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)') |
176 | 158 | 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() |
178 | 192 |
|
179 | 193 | 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) |
0 commit comments