podcast.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. import os
  2. import time
  3. from typing import Optional, Tuple
  4. from librespot.metadata import EpisodeId
  5. from const import ERROR, ID, ITEMS, NAME, SHOW, DURATION_MS
  6. from termoutput import PrintChannel, Printer
  7. from utils import create_download_directory, fix_filename
  8. from zspotify import ZSpotify
  9. from loader import Loader
  10. EPISODE_INFO_URL = 'https://api.spotify.com/v1/episodes'
  11. SHOWS_URL = 'https://api.spotify.com/v1/shows'
  12. def get_episode_info(episode_id_str) -> Tuple[Optional[str], Optional[str]]:
  13. with Loader(PrintChannel.PROGRESS_INFO, "Fetching episode information..."):
  14. (raw, info) = ZSpotify.invoke_url(f'{EPISODE_INFO_URL}/{episode_id_str}')
  15. if not info:
  16. Printer.print(PrintChannel.ERRORS, "### INVALID EPISODE ID ###")
  17. duration_ms = info[DURATION_MS]
  18. if ERROR in info:
  19. return None, None
  20. return fix_filename(info[SHOW][NAME]), duration_ms, fix_filename(info[NAME])
  21. def get_show_episodes(show_id_str) -> list:
  22. episodes = []
  23. offset = 0
  24. limit = 50
  25. with Loader(PrintChannel.PROGRESS_INFO, "Fetching episodes..."):
  26. while True:
  27. resp = ZSpotify.invoke_url_with_params(
  28. f'{SHOWS_URL}/{show_id_str}/episodes', limit=limit, offset=offset)
  29. offset += limit
  30. for episode in resp[ITEMS]:
  31. episodes.append(episode[ID])
  32. if len(resp[ITEMS]) < limit:
  33. break
  34. return episodes
  35. def download_podcast_directly(url, filename):
  36. import functools
  37. import pathlib
  38. import shutil
  39. import requests
  40. from tqdm.auto import tqdm
  41. r = requests.get(url, stream=True, allow_redirects=True)
  42. if r.status_code != 200:
  43. r.raise_for_status() # Will only raise for 4xx codes, so...
  44. raise RuntimeError(
  45. f"Request to {url} returned status code {r.status_code}")
  46. file_size = int(r.headers.get('Content-Length', 0))
  47. path = pathlib.Path(filename).expanduser().resolve()
  48. path.parent.mkdir(parents=True, exist_ok=True)
  49. desc = "(Unknown total file size)" if file_size == 0 else ""
  50. r.raw.read = functools.partial(
  51. r.raw.read, decode_content=True) # Decompress if needed
  52. with tqdm.wrapattr(r.raw, "read", total=file_size, desc=desc) as r_raw:
  53. with path.open("wb") as f:
  54. shutil.copyfileobj(r_raw, f)
  55. return path
  56. def download_episode(episode_id) -> None:
  57. podcast_name, duration_ms, episode_name = get_episode_info(episode_id)
  58. extra_paths = podcast_name + '/'
  59. prepare_download_loader = Loader(PrintChannel.PROGRESS_INFO, "Preparing download...")
  60. prepare_download_loader.start()
  61. if podcast_name is None:
  62. Printer.print(PrintChannel.SKIPS, '### SKIPPING: (EPISODE NOT FOUND) ###')
  63. prepare_download_loader.stop()
  64. else:
  65. filename = podcast_name + ' - ' + episode_name
  66. direct_download_url = ZSpotify.invoke_url(
  67. 'https://api-partner.spotify.com/pathfinder/v1/query?operationName=getEpisode&variables={"uri":"spotify:episode:' + episode_id + '"}&extensions={"persistedQuery":{"version":1,"sha256Hash":"224ba0fd89fcfdfb3a15fa2d82a6112d3f4e2ac88fba5c6713de04d1b72cf482"}}')[1]["data"]["episode"]["audio"]["items"][-1]["url"]
  68. download_directory = os.path.join(ZSpotify.CONFIG.get_root_podcast_path(), extra_paths)
  69. download_directory = os.path.realpath(download_directory)
  70. create_download_directory(download_directory)
  71. if "anon-podcast.scdn.co" in direct_download_url:
  72. episode_id = EpisodeId.from_base62(episode_id)
  73. stream = ZSpotify.get_content_stream(
  74. episode_id, ZSpotify.DOWNLOAD_QUALITY)
  75. total_size = stream.input_stream.size
  76. filepath = os.path.join(download_directory, f"{filename}.ogg")
  77. if (
  78. os.path.isfile(filepath)
  79. and os.path.getsize(filepath) == total_size
  80. and ZSpotify.CONFIG.get_skip_existing_files()
  81. ):
  82. Printer.print(PrintChannel.SKIPS, "\n### SKIPPING: " + podcast_name + " - " + episode_name + " (EPISODE ALREADY EXISTS) ###")
  83. prepare_download_loader.stop()
  84. return
  85. prepare_download_loader.stop()
  86. time_start = time.time()
  87. downloaded = 0
  88. with open(filepath, 'wb') as file, Printer.progress(
  89. desc=filename,
  90. total=total_size,
  91. unit='B',
  92. unit_scale=True,
  93. unit_divisor=1024
  94. ) as p_bar:
  95. prepare_download_loader.stop()
  96. for _ in range(int(total_size / ZSpotify.CONFIG.get_chunk_size()) + 1):
  97. data = stream.input_stream.stream().read(ZSpotify.CONFIG.get_chunk_size())
  98. p_bar.update(file.write(data))
  99. downloaded += len(data)
  100. if ZSpotify.CONFIG.get_download_real_time():
  101. delta_real = time.time() - time_start
  102. delta_want = (downloaded / total_size) * (duration_ms/1000)
  103. if delta_want > delta_real:
  104. time.sleep(delta_want - delta_real)
  105. else:
  106. filepath = os.path.join(download_directory, f"{filename}.mp3")
  107. download_podcast_directly(direct_download_url, filepath)
  108. prepare_download_loader.stop()