librespot-python/librespot/audio/cdn/CdnManager.py

342 lines
12 KiB
Python
Raw Normal View History

2021-02-24 00:46:59 +01:00
from __future__ import annotations
2021-05-21 05:50:09 +02:00
2021-02-24 00:46:59 +01:00
import concurrent.futures
import logging
import math
import random
import time
import typing
import urllib.parse
2021-05-22 03:26:35 +02:00
from librespot.audio import GeneralAudioStream, GeneralWritableStream, StreamId
2021-05-21 05:50:09 +02:00
from librespot.audio.AbsChunkedInputStream import AbsChunkedInputStream
2021-05-22 03:26:35 +02:00
from librespot.audio.decrypt import AesAudioDecrypt, NoopAudioDecrypt
2021-05-21 05:50:09 +02:00
from librespot.audio.format import SuperAudioFormat
from librespot.audio.storage import ChannelManager
from librespot.common import Utils
from librespot.proto import StorageResolve
2021-02-24 00:46:59 +01:00
if typing.TYPE_CHECKING:
from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt
2021-05-21 05:50:09 +02:00
from librespot.audio.HaltListener import HaltListener
2021-02-24 00:46:59 +01:00
from librespot.cache.CacheManager import CacheManager
from librespot.core.Session import Session
from librespot.proto import Metadata
class CdnManager:
_LOGGER: logging = logging.getLogger(__name__)
_session: Session = None
def __init__(self, session: Session):
self._session = session
def get_head(self, file_id: bytes):
2021-05-21 05:50:07 +02:00
resp = self._session.client().get(
self._session.get_user_attribute(
2021-05-22 03:26:33 +02:00
"head-files-url", "https://heads-fa.spotify.com/head/{file_id}"
).replace("{file_id}", Utils.bytes_to_hex(file_id))
)
2021-02-24 00:46:59 +01:00
if resp.status_code != 200:
raise IOError("{}".format(resp.status_code))
body = resp.content
if body is None:
raise IOError("Response body is empty!")
return body
2021-05-22 03:26:33 +02:00
def stream_external_episode(
self, episode: Metadata.Episode, external_url: str, halt_listener: HaltListener
):
2021-05-21 05:50:07 +02:00
return CdnManager.Streamer(
self._session,
StreamId(episode),
SuperAudioFormat.MP3,
CdnManager.CdnUrl(self, None, external_url),
self._session.cache(),
NoopAudioDecrypt(),
halt_listener,
)
def stream_file(
self,
file: Metadata.AudioFile,
key: bytes,
url: str,
halt_listener: HaltListener,
):
return CdnManager.Streamer(
self._session,
StreamId.StreamId(file),
SuperAudioFormat.get(file.format),
CdnManager.CdnUrl(self, file.file_id, url),
self._session.cache(),
AesAudioDecrypt(key),
halt_listener,
)
2021-02-24 00:46:59 +01:00
def get_audio_url(self, file_id: bytes):
resp = self._session.api().send(
2021-05-21 05:50:07 +02:00
"GET",
"/storage-resolve/files/audio/interactive/{}".format(
2021-05-22 03:26:33 +02:00
Utils.bytes_to_hex(file_id)
),
2021-05-21 05:50:07 +02:00
None,
None,
)
2021-02-24 00:46:59 +01:00
if resp.status_code != 200:
raise IOError(resp.status_code)
body = resp.content
if body is None:
raise IOError("Response body is empty!")
proto = StorageResolve.StorageResolveResponse()
proto.ParseFromString(body)
if proto.result == StorageResolve.StorageResolveResponse.Result.CDN:
url = random.choice(proto.cdnurl)
2021-05-22 03:26:33 +02:00
self._LOGGER.debug(
"Fetched CDN url for {}: {}".format(Utils.bytes_to_hex(file_id), url)
)
2021-02-24 00:46:59 +01:00
return url
raise CdnManager.CdnException(
2021-05-22 03:26:33 +02:00
"Could not retrieve CDN url! result: {}".format(proto.result)
)
2021-02-24 00:46:59 +01:00
class CdnException(Exception):
pass
2021-02-24 00:46:59 +01:00
class InternalResponse:
_buffer: bytearray
2021-04-24 12:17:02 +02:00
_headers: typing.Dict[str, str]
2021-02-24 00:46:59 +01:00
2021-04-24 12:17:02 +02:00
def __init__(self, buffer: bytearray, headers: typing.Dict[str, str]):
2021-02-24 00:46:59 +01:00
self._buffer = buffer
self._headers = headers
class CdnUrl:
_cdnManager = None
_fileId: bytes
_expiration: int
_url: str
def __init__(self, cdn_manager, file_id: bytes, url: str):
self._cdnManager: CdnManager = cdn_manager
self._fileId = file_id
self.set_url(url)
def url(self):
if self._expiration == -1:
return self._url
if self._expiration <= int(time.time() * 1000) + 5 * 60 * 1000:
self._url = self._cdnManager.get_audio_url(self._fileId)
return self.url
def set_url(self, url: str):
self._url = url
if self._fileId is not None:
token_url = urllib.parse.urlparse(url)
token_query = urllib.parse.parse_qs(token_url.query)
2021-04-25 02:15:56 +02:00
token_list = token_query.get("__token__")
try:
token_str = str(token_list[0])
except TypeError:
token_str = ""
2021-02-24 00:46:59 +01:00
if token_str != "None" and len(token_str) != 0:
expire_at = None
split = token_str.split("~")
for s in split:
try:
2021-05-21 05:49:35 +02:00
i = s.index("=")
2021-02-24 00:46:59 +01:00
except ValueError:
continue
if s[0][:i] == "exp":
2021-05-22 03:26:33 +02:00
expire_at = int(s[0][i + 1 :])
2021-02-24 00:46:59 +01:00
break
if expire_at is None:
self._expiration = -1
self._cdnManager._LOGGER.warning(
2021-05-22 03:26:33 +02:00
"Invalid __token__ in CDN url: {}".format(url)
)
2021-02-24 00:46:59 +01:00
return
self._expiration = expire_at * 1000
else:
try:
i = token_url.query.index("_")
except ValueError:
self._expiration = -1
self._cdnManager._LOGGER.warning(
2021-05-22 03:26:33 +02:00
"Couldn't extract expiration, invalid parameter in CDN url: ".format(
url
)
)
2021-02-24 00:46:59 +01:00
return
self._expiration = int(token_url.query[:i]) * 1000
else:
self._expiration = -1
2021-05-21 05:50:07 +02:00
class Streamer(
2021-05-22 03:26:33 +02:00
GeneralAudioStream.GeneralAudioStream,
GeneralWritableStream.GeneralWritableStream,
2021-05-21 05:50:07 +02:00
):
2021-02-24 00:46:59 +01:00
_session: Session = None
_streamId: StreamId = None
_executorService = concurrent.futures.ThreadPoolExecutor()
_audioFormat: SuperAudioFormat = None
_audioDecrypt: AudioDecrypt = None
_cdnUrl = None
_size: int
2021-04-24 12:17:02 +02:00
_buffer: typing.List[bytearray]
_available: typing.List[bool]
_requested: typing.List[bool]
2021-02-24 00:46:59 +01:00
_chunks: int
_internalStream: CdnManager.Streamer.InternalStream = None
_haltListener: HaltListener = None
2021-05-21 05:50:07 +02:00
def __init__(
self,
session: Session,
stream_id: StreamId,
audio_format: SuperAudioFormat,
cdn_url,
cache: CacheManager,
audio_decrypt: AudioDecrypt,
halt_listener: HaltListener,
):
2021-02-24 00:46:59 +01:00
self._session = session
self._streamId = stream_id
self._audioFormat = audio_format
self._audioDecrypt = audio_decrypt
self._cdnUrl = cdn_url
self._haltListener = halt_listener
2021-05-22 03:26:33 +02:00
resp = self.request(range_start=0, range_end=ChannelManager.CHUNK_SIZE - 1)
2021-02-24 00:46:59 +01:00
content_range = resp._headers.get("Content-Range")
if content_range is None:
raise IOError("Missing Content-Range header!")
split = Utils.split(content_range, "/")
self._size = int(split[1])
2021-05-22 03:26:33 +02:00
self._chunks = int(math.ceil(self._size / ChannelManager.CHUNK_SIZE))
2021-02-24 00:46:59 +01:00
first_chunk = resp._buffer
self._available = [False for _ in range(self._chunks)]
self._requested = [False for _ in range(self._chunks)]
self._buffer = [bytearray() for _ in range(self._chunks)]
2021-05-22 03:26:33 +02:00
self._internalStream = CdnManager.Streamer.InternalStream(self, False)
2021-02-24 00:46:59 +01:00
self._requested[0] = True
self.write_chunk(first_chunk, 0, False)
2021-05-22 03:26:33 +02:00
def write_chunk(self, chunk: bytes, chunk_index: int, cached: bool) -> None:
2021-02-24 00:46:59 +01:00
if self._internalStream.is_closed():
return
self._session._LOGGER.debug(
"Chunk {}/{} completed, cached: {}, stream: {}".format(
2021-05-22 03:26:33 +02:00
chunk_index + 1, self._chunks, cached, self.describe()
)
)
2021-02-24 00:46:59 +01:00
self._buffer[chunk_index] = self._audioDecrypt.decrypt_chunk(
2021-05-22 03:26:33 +02:00
chunk_index, chunk
)
2021-02-24 00:46:59 +01:00
self._internalStream.notify_chunk_available(chunk_index)
def stream(self) -> AbsChunkedInputStream:
return self._internalStream
def codec(self) -> SuperAudioFormat:
return self._audioFormat
def describe(self) -> str:
if self._streamId.is_episode():
2021-05-22 03:26:33 +02:00
return "episode_gid: {}".format(self._streamId.get_episode_gid())
return "file_id: {}".format(self._streamId.get_file_id())
2021-02-24 00:46:59 +01:00
def decrypt_time_ms(self) -> int:
return self._audioDecrypt.decrypt_time_ms()
def request_chunk(self, index: int) -> None:
resp = self.request(index)
self.write_chunk(resp._buffer, index, False)
2021-05-22 03:26:33 +02:00
def request(
self, chunk: int = None, range_start: int = None, range_end: int = None
) -> CdnManager.InternalResponse:
2021-02-24 00:46:59 +01:00
if chunk is None and range_start is None and range_end is None:
raise TypeError()
if chunk is not None:
range_start = ChannelManager.CHUNK_SIZE * chunk
range_end = (chunk + 1) * ChannelManager.CHUNK_SIZE - 1
2021-05-21 05:50:07 +02:00
resp = self._session.client().get(
self._cdnUrl._url,
2021-05-22 03:26:33 +02:00
headers={"Range": "bytes={}-{}".format(range_start, range_end)},
2021-05-21 05:50:07 +02:00
)
2021-02-24 00:46:59 +01:00
if resp.status_code != 206:
raise IOError(resp.status_code)
body = resp.content
if body is None:
raise IOError("Response body is empty!")
return CdnManager.InternalResponse(bytearray(body), resp.headers)
class InternalStream(AbsChunkedInputStream):
streamer = None
def __init__(self, streamer, retry_on_chunk_error: bool):
self.streamer: CdnManager.Streamer = streamer
super().__init__(retry_on_chunk_error)
2021-04-24 12:17:02 +02:00
def buffer(self) -> typing.List[bytearray]:
2021-02-24 00:46:59 +01:00
return self.streamer._buffer
def size(self) -> int:
return self.streamer._size
2021-04-24 12:17:02 +02:00
def requested_chunks(self) -> typing.List[bool]:
2021-02-24 00:46:59 +01:00
return self.streamer._requested
2021-04-24 12:17:02 +02:00
def available_chunks(self) -> typing.List[bool]:
2021-02-24 00:46:59 +01:00
return self.streamer._available
def chunks(self) -> int:
return self.streamer._chunks
def request_chunk_from_stream(self, index: int) -> None:
self.streamer._executorService.submit(
2021-05-22 03:26:33 +02:00
lambda: self.streamer.request_chunk(index)
)
2021-02-24 00:46:59 +01:00
def stream_read_halted(self, chunk: int, _time: int) -> None:
if self.streamer._haltListener is not None:
self.streamer._executorService.submit(
lambda: self.streamer._haltListener.stream_read_halted(
2021-05-22 03:26:33 +02:00
chunk, _time
)
)
2021-02-24 00:46:59 +01:00
def stream_read_resumed(self, chunk: int, _time: int) -> None:
if self.streamer._haltListener is not None:
self.streamer._executorService.submit(
2021-05-22 03:26:33 +02:00
lambda: self.streamer._haltListener.stream_read_resumed(
chunk, _time
)
)