Change Directory

This commit is contained in:
kokarare1212
2021-02-25 08:07:17 +09:00
parent 01dae8ada4
commit 8daf68831e
133 changed files with 221 additions and 38 deletions

View File

@@ -0,0 +1,231 @@
from librespot.audio.HaltListener import HaltListener
from librespot.audio.storage import ChannelManager
from librespot.standard.InputStream import InputStream
import math
import threading
import time
import typing
class AbsChunkedInputStream(InputStream, HaltListener):
preload_ahead: typing.Final[int] = 3
preload_chunk_retries: typing.Final[int] = 2
max_chunk_tries: typing.Final[int] = 128
wait_lock: threading.Condition = threading.Condition()
retries: list[int]
retry_on_chunk_error: bool
chunk_exception = None
wait_for_chunk: int = -1
_pos: int = 0
_mark: int = 0
closed: bool = False
_decoded_length: int = 0
def __init__(self, retry_on_chunk_error: bool):
self.retries: typing.Final[list[int]] = [
0 for _ in range(self.chunks())
]
self.retry_on_chunk_error = retry_on_chunk_error
def is_closed(self) -> bool:
return self.closed
def buffer(self) -> list[bytearray]:
raise NotImplementedError()
def size(self) -> int:
raise NotImplementedError()
def close(self) -> None:
self.closed = True
with self.wait_lock:
self.wait_lock.notify_all()
def available(self):
return self.size() - self._pos
def mark_supported(self) -> bool:
return True
def mark(self, read_ahead_limit: int) -> None:
self._mark = self._pos
def reset(self) -> None:
self._pos = self._mark
def pos(self) -> int:
return self._pos
def seek(self, where: int) -> None:
if where < 0:
raise TypeError()
if self.closed:
raise IOError("Stream is closed!")
self._pos = where
self.check_availability(int(self._pos / ChannelManager.CHUNK_SIZE),
False, False)
def skip(self, n: int) -> int:
if n < 0:
raise TypeError()
if self.closed:
raise IOError("Stream is closed!")
k = self.size() - self._pos
if n < k:
k = n
self._pos += k
chunk = int(self._pos / ChannelManager.CHUNK_SIZE)
self.check_availability(chunk, False, False)
return k
def requested_chunks(self) -> list[bool]:
raise NotImplementedError()
def available_chunks(self) -> list[bool]:
raise NotImplementedError()
def chunks(self) -> int:
raise NotImplementedError()
def request_chunk_from_stream(self, index: int) -> None:
raise NotImplementedError()
def should_retry(self, chunk: int) -> bool:
if self.retries[chunk] < 1:
return True
if self.retries[chunk] > self.max_chunk_tries:
return False
return self.retry_on_chunk_error
def check_availability(self, chunk: int, wait: bool, halted: bool) -> None:
if halted and not wait:
raise TypeError()
if not self.requested_chunks()[chunk]:
self.request_chunk_from_stream(chunk)
self.requested_chunks()[chunk] = True
for i in range(chunk + 1,
min(self.chunks() - 1, chunk + self.preload_ahead) + 1):
if self.requested_chunks(
)[i] and self.retries[i] < self.preload_chunk_retries:
self.request_chunk_from_stream(i)
self.requested_chunks()[chunk] = True
if wait:
if self.available_chunks()[chunk]:
return
retry = False
with self.wait_lock:
if not halted:
self.stream_read_halted(chunk, int(time.time() * 1000))
self.chunk_exception = None
self.wait_for_chunk = chunk
self.wait_lock.wait()
if self.closed:
return
if self.chunk_exception is not None:
if self.should_retry(chunk):
retry = True
else:
raise AbsChunkedInputStream.ChunkException
if not retry:
self.stream_read_halted(chunk, int(time.time() * 1000))
if retry:
time.sleep(math.log10(self.retries[chunk]))
self.check_availability(chunk, True, True)
def read(self,
b: bytearray = None,
offset: int = None,
length: int = None) -> int:
if b is None and offset is None and length is None:
return self.internal_read()
elif not (b is not None and offset is not None and length is not None):
raise TypeError()
if self.closed:
raise IOError("Stream is closed!")
if offset < 0 or length < 0 or length > len(b) - offset:
raise IndexError("offset: {}, length: {}, buffer: {}".format(
offset, length, len(b)))
elif length == 0:
return 0
if self._pos >= self.size():
return -1
i = 0
while True:
chunk = int(self._pos / ChannelManager.CHUNK_SIZE)
chunk_off = int(self._pos % ChannelManager.CHUNK_SIZE)
self.check_availability(chunk, True, False)
copy = min(len(self.buffer()[chunk]) - chunk_off, length - i)
b[offset + 0:copy] = self.buffer()[chunk][chunk_off:chunk_off +
copy]
i += copy
self._pos += copy
if i == length or self._pos >= self.size():
return i
def internal_read(self) -> int:
if self.closed:
raise IOError("Stream is closed!")
if self._pos >= self.size():
return -1
chunk = int(self._pos / ChannelManager.CHUNK_SIZE)
self.check_availability(chunk, True, False)
b = self.buffer()[chunk][self._pos % ChannelManager.CHUNK_SIZE]
self._pos = self._pos + 1
return b
def notify_chunk_available(self, index: int) -> None:
self.available_chunks()[index] = True
self._decoded_length += len(self.buffer()[index])
with self.wait_lock:
if index == self.wait_for_chunk and not self.closed:
self.wait_for_chunk = -1
self.wait_lock.notify_all()
def notify_chunk_error(self, index: int, ex):
self.available_chunks()[index] = False
self.requested_chunks()[index] = False
self.retries[index] += 1
with self.wait_lock:
if index == self.wait_for_chunk and not self.closed:
self.chunk_exception = ex
self.wait_for_chunk = -1
self.wait_lock.notify_all()
def decoded_length(self):
return self._decoded_length
class ChunkException(IOError):
def __init__(self, cause):
super().__init__(cause)
@staticmethod
def from_stream_error(stream_error: int):
return AbsChunkedInputStream.ChunkException(
"Failed due to stream error, code: {}".format(stream_error))

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from librespot.common import Utils
from librespot.core import Session
from librespot.core.PacketsReceiver import PacketsReceiver
from librespot.crypto import Packet
from librespot.standard import BytesInputStream, BytesOutputStream
import logging
import queue
import threading
class AudioKeyManager(PacketsReceiver):
_ZERO_SHORT: bytes = bytes([0, 0])
_LOGGER: logging = logging.getLogger(__name__)
_AUDIO_KEY_REQUEST_TIMEOUT: int = 20
_seqHolder: int = 0
_seqHolderLock: threading.Condition = threading.Condition()
_callbacks: dict[int, AudioKeyManager.Callback] = dict()
_session: Session = None
def __init__(self, session: Session):
self._session = session
def get_audio_key(self,
gid: bytes,
file_id: bytes,
retry: bool = True) -> bytes:
seq: int
with self._seqHolderLock:
seq = self._seqHolder
self._seqHolder += 1
out = BytesOutputStream()
out.write(file_id)
out.write(gid)
out.write_int(seq)
out.write(self._ZERO_SHORT)
self._session.send(Packet.Type.request_key, out.buffer)
callback = AudioKeyManager.SyncCallback(self)
self._callbacks[seq] = callback
key = callback.wait_response()
if key is None:
if retry:
return self.get_audio_key(gid, file_id, False)
else:
raise RuntimeError(
"Failed fetching audio key! gid: {}, fileId: {}".format(
Utils.Utils.bytes_to_hex(gid),
Utils.Utils.bytes_to_hex(file_id)))
return key
def dispatch(self, packet: Packet) -> None:
payload = BytesInputStream(packet.payload)
seq = payload.read_int()
callback = self._callbacks.get(seq)
if callback is None:
self._LOGGER.warning(
"Couldn't find callback for seq: {}".format(seq))
return
if packet.is_cmd(Packet.Type.aes_key):
key = payload.read(16)
callback.key(key)
elif packet.is_cmd(Packet.Type.aes_key_error):
code = payload.read_short()
callback.error(code)
else:
self._LOGGER.warning(
"Couldn't handle packet, cmd: {}, length: {}".format(
packet.cmd, len(packet.payload)))
class Callback:
def key(self, key: bytes) -> None:
pass
def error(self, code: int) -> None:
pass
class SyncCallback(Callback):
_audioKeyManager: AudioKeyManager
reference = queue.Queue()
reference_lock = threading.Condition()
def __init__(self, audio_key_manager: AudioKeyManager):
self._audioKeyManager = audio_key_manager
def key(self, key: bytes) -> None:
with self.reference_lock:
self.reference.put(key)
self.reference_lock.notify_all()
def error(self, code: int) -> None:
self._audioKeyManager._LOGGER.fatal(
"Audio key error, code: {}".format(code))
with self.reference_lock:
self.reference.put(None)
self.reference_lock.notify_all()
def wait_response(self) -> bytes:
with self.reference_lock:
self.reference_lock.wait(
AudioKeyManager._AUDIO_KEY_REQUEST_TIMEOUT)
return self.reference.get(block=False)
class AesKeyException(IOError):
def __init__(self, ex):
super().__init__(ex)

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import typing
if typing.TYPE_CHECKING:
from librespot.audio import AbsChunkedInputStream
from librespot.audio.format import SuperAudioFormat
class GeneralAudioStream:
def stream(self) -> AbsChunkedInputStream:
pass
def codec(self) -> SuperAudioFormat:
pass
def describe(self) -> str:
pass
def decrypt_time_ms(self) -> int:
pass

View File

@@ -0,0 +1,3 @@
class GeneralWritableStream:
def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool):
pass

View File

@@ -0,0 +1,6 @@
class HaltListener:
def stream_read_halted(self, chunk: int, _time: int) -> None:
pass
def stream_read_resumed(self, chunk: int, _time: int):
pass

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from librespot.standard import BytesInputStream, DataInputStream, InputStream
import logging
import math
class NormalizationData:
_LOGGER: logging = logging.getLogger(__name__)
track_gain_db: float
track_peak: float
album_gain_db: float
album_peak: float
def __init__(self, track_gain_db: float, track_peak: float,
album_gain_db: float, album_peak: float):
self.track_gain_db = track_gain_db
self.track_peak = track_peak
self.album_gain_db = album_gain_db
self.album_peak = album_peak
self._LOGGER.debug(
"Loaded normalization data, track_gain: {}, track_peak: {}, album_gain: {}, album_peak: {}"
.format(track_gain_db, track_peak, album_gain_db, album_peak))
@staticmethod
def read(input_stream: InputStream) -> NormalizationData:
data_input = DataInputStream(input_stream)
data_input.mark(16)
skip_bytes = data_input.skip_bytes(144)
if skip_bytes != 144:
raise IOError()
data = bytearray(4 * 4)
data_input.read_fully(data)
data_input.reset()
buffer = BytesInputStream(data, "<")
return NormalizationData(buffer.read_float(), buffer.read_float(),
buffer.read_float(), buffer.read_float())
def get_factor(self, normalisation_pregain) -> float:
normalisation_factor = float(
math.pow(10, (self.track_gain_db + normalisation_pregain) / 20))
if normalisation_factor * self.track_peak > 1:
self._LOGGER.warning(
"Reducing normalisation factor to prevent clipping. Please add negative pregain to avoid."
)
normalisation_factor = 1 / self.track_peak
return normalisation_factor

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from librespot.audio import GeneralAudioStream, HaltListener, NormalizationData
from librespot.audio.cdn import CdnFeedHelper
from librespot.audio.format import AudioQualityPicker
from librespot.common.Utils import Utils
from librespot.core import Session
from librespot.metadata.PlayableId import PlayableId
from librespot.metadata.TrackId import TrackId
from librespot.proto import Metadata, StorageResolve
import logging
import typing
class PlayableContentFeeder:
_LOGGER: logging = logging.getLogger(__name__)
STORAGE_RESOLVE_INTERACTIVE: str = "/storage-resolve/files/audio/interactive/{}"
STORAGE_RESOLVE_INTERACTIVE_PREFETCH: str = "/storage-resolve/files/audio/interactive_prefetch/{}"
session: Session
def __init__(self, session: Session):
self.session = session
def pick_alternative_if_necessary(self, track: Metadata.Track):
if len(track.file) > 0:
return track
for alt in track.alternative_list:
if len(alt.file) > 0:
pass
return None
def load(self, playable_id: PlayableId,
audio_quality_picker: AudioQualityPicker, preload: bool,
halt_listener: HaltListener):
if type(playable_id) is TrackId:
return self.load_track(playable_id, audio_quality_picker, preload,
halt_listener)
def resolve_storage_interactive(
self, file_id: bytes,
preload: bool) -> StorageResolve.StorageResolveResponse:
resp = self.session.api().send(
"GET", (self.STORAGE_RESOLVE_INTERACTIVE_PREFETCH
if preload else self.STORAGE_RESOLVE_INTERACTIVE).format(
Utils.bytes_to_hex(file_id)), None, None)
if resp.status_code != 200:
raise RuntimeError(resp.status_code)
body = resp.content
if body is None:
RuntimeError("Response body is empty!")
storage_resolve_response = StorageResolve.StorageResolveResponse()
storage_resolve_response.ParseFromString(body)
return storage_resolve_response
def load_track(self, track_id_or_track: typing.Union[TrackId,
Metadata.Track],
audio_quality_picker: AudioQualityPicker, preload: bool,
halt_listener: HaltListener):
if type(track_id_or_track) is TrackId:
original = self.session.api().get_metadata_4_track(
track_id_or_track)
track = self.pick_alternative_if_necessary(original)
if track is None:
raise
else:
track = track_id_or_track
file = audio_quality_picker.get_file(track.file)
if file is None:
self._LOGGER.fatal(
"Couldn't find any suitable audio file, available")
raise
return self.load_stream(file, track, None, preload, halt_listener)
def load_stream(self, file: Metadata.AudioFile, track: Metadata.Track,
episode: Metadata.Episode, preload: bool,
halt_lister: HaltListener):
if track is None and episode is None:
raise RuntimeError()
resp = self.resolve_storage_interactive(file.file_id, preload)
if resp.result == StorageResolve.StorageResolveResponse.Result.CDN:
if track is not None:
return CdnFeedHelper.load_track(self.session, track, file,
resp, preload, halt_lister)
else:
return CdnFeedHelper.load_episode(self.session, episode, file,
resp, preload, halt_lister)
elif resp.result == StorageResolve.StorageResolveResponse.Result.STORAGE:
if track is None:
# return StorageFeedHelper
pass
elif resp.result == StorageResolve.StorageResolveResponse.Result.RESTRICTED:
raise RuntimeError("Content is restricted!")
elif resp.result == StorageResolve.StorageResolveResponse.Response.UNRECOGNIZED:
raise RuntimeError("Content is unrecognized!")
else:
raise RuntimeError("Unknown result: {}".format(resp.result))
class LoadedStream:
episode: Metadata.Episode
track: Metadata.Track
input_stream: GeneralAudioStream
normalization_data: NormalizationData
metrics: PlayableContentFeeder.Metrics
def __init__(self, track_or_episode: typing.Union[Metadata.Track,
Metadata.Episode],
input_stream: GeneralAudioStream,
normalization_data: NormalizationData,
metrics: PlayableContentFeeder.Metrics):
if type(track_or_episode) is Metadata.Track:
self.track = track_or_episode
self.episode = None
elif type(track_or_episode) is Metadata.Episode:
self.track = None
self.episode = track_or_episode
else:
raise TypeError()
self.input_stream = input_stream
self.normalization_data = normalization_data
self.metrics = metrics
class Metrics:
file_id: str
preloaded_audio_key: bool
audio_key_time: int
def __init__(self, file_id: bytes, preloaded_audio_key: bool,
audio_key_time: int):
self.file_id = None if file_id is None else Utils.bytes_to_hex(
file_id)
self.preloaded_audio_key = preloaded_audio_key
self.audio_key_time = audio_key_time
if preloaded_audio_key and audio_key_time != -1:
raise RuntimeError()

View File

@@ -0,0 +1,30 @@
from librespot.common.Utils import Utils
from librespot.proto import Metadata
class StreamId:
file_id: bytes = None
episode_gid: bytes = None
def __init__(self,
file: Metadata.AudioFile = None,
episode: Metadata.Episode = None):
if file is None and episode is None:
return
if file is not None:
self.file_id = file.file_id
if episode is not None:
self.episode_gid = episode.gid
def get_file_id(self):
if self.file_id is None:
raise RuntimeError("Not a file!")
return Utils.bytes_to_hex(self.file_id)
def is_episode(self):
return self.episode_gid is not None
def get_episode_gid(self):
if self.episode_gid is None:
raise RuntimeError("Not an episode!")
return Utils.bytes_to_hex(self.episode_gid)

View File

@@ -0,0 +1,8 @@
from librespot.audio.AbsChunkedInputStream import AbsChunkedInputStream
from librespot.audio.AudioKeyManager import AudioKeyManager
from librespot.audio.GeneralAudioStream import GeneralAudioStream
from librespot.audio.GeneralWritableStream import GeneralWritableStream
from librespot.audio.HaltListener import HaltListener
from librespot.audio.NormalizationData import NormalizationData
from librespot.audio.PlayableContentFeeder import PlayableContentFeeder
from librespot.audio.StreamId import StreamId

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from librespot.audio import NormalizationData, PlayableContentFeeder, HaltListener
from librespot.common import Utils
from librespot.core import Session
from librespot.proto import Metadata, StorageResolve
import logging
import random
import time
import typing
class CdnFeedHelper:
_LOGGER: logging = logging.getLogger(__name__)
@staticmethod
def get_url(resp: StorageResolve.StorageResolveResponse) -> str:
return random.choice(resp.cdnurl)
@staticmethod
def load_track(session: Session, track: Metadata.Track, file: Metadata.AudioFile,
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str],
preload: bool, halt_listener: HaltListener)\
-> PlayableContentFeeder.PlayableContentFeeder.LoadedStream:
if type(resp_or_url) is str:
url = resp_or_url
else:
url = CdnFeedHelper.get_url(resp_or_url)
start = int(time.time() * 1000)
key = session.audio_key().get_audio_key(track.gid, file.file_id)
audio_key_time = int(time.time() * 1000) - start
streamer = session.cdn().stream_file(file, key, url, halt_listener)
input_stream = streamer.stream()
normalization_data = NormalizationData.read(input_stream)
if input_stream.skip(0xa7) != 0xa7:
raise IOError("Couldn't skip 0xa7 bytes!")
return PlayableContentFeeder.PlayableContentFeeder.LoadedStream(
track, streamer, normalization_data,
PlayableContentFeeder.PlayableContentFeeder.Metrics(
file.file_id, preload, audio_key_time))
@staticmethod
def load_episode_external(
session: Session, episode: Metadata.Episode,
halt_listener: HaltListener
) -> PlayableContentFeeder.PlayableContentFeeder.LoadedStream:
resp = session.client().head(episode.external_url)
if resp.status_code != 200:
CdnFeedHelper._LOGGER.warning("Couldn't resolve redirect!")
url = resp.url
CdnFeedHelper._LOGGER.debug("Fetched external url for {}: {}".format(
Utils.Utils.bytes_to_hex(episode.gid), url))
streamer = session.cdn().stream_external_episode(
episode, url, halt_listener)
return PlayableContentFeeder.PlayableContentFeeder.LoadedStream(
episode, streamer, None,
PlayableContentFeeder.PlayableContentFeeder.Metrics(
None, False, -1))
@staticmethod
def load_episode(
session: Session, episode: Metadata.Episode, file: Metadata.AudioFile,
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse,
str], halt_listener: HaltListener
) -> PlayableContentFeeder.PlayableContentFeeder.LoadedStream:
if type(resp_or_url) is str:
url = resp_or_url
else:
url = CdnFeedHelper.get_url(resp_or_url)
start = int(time.time() * 1000)
key = session.audio_key().get_audio_key(episode.gid, file.file_id)
audio_key_time = int(time.time() * 1000) - start
streamer = session.cdn().stream_file(file, key, url, halt_listener)
input_stream = streamer.stream()
normalization_data = NormalizationData.read(input_stream)
if input_stream.skip(0xa7) != 0xa7:
raise IOError("Couldn't skip 0xa7 bytes!")
return PlayableContentFeeder.PlayableContentFeeder.LoadedStream(
episode, streamer, normalization_data,
PlayableContentFeeder.PlayableContentFeeder.Metrics(
file.file_id, False, audio_key_time))

View File

@@ -0,0 +1,307 @@
from __future__ import annotations
from librespot.audio.AbsChunkedInputStream import AbsChunkedInputStream
from librespot.audio import GeneralAudioStream, GeneralWritableStream, StreamId
from librespot.audio.decrypt import AesAudioDecrypt, NoopAudioDecrypt
from librespot.audio.format import SuperAudioFormat
from librespot.audio.storage import ChannelManager
from librespot.common import Utils
from librespot.proto import StorageResolve
import concurrent.futures
import logging
import math
import random
import time
import typing
import urllib.parse
if typing.TYPE_CHECKING:
from librespot.audio.HaltListener import HaltListener
from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt
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):
resp = self._session.client() \
.get(self._session.get_user_attribute("head-files-url", "https://heads-fa.spotify.com/head/{file_id}")
.replace("{file_id}", Utils.bytes_to_hex(file_id)))
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
def stream_external_episode(self, episode: Metadata.Episode,
external_url: str,
halt_listener: HaltListener):
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)
def get_audio_url(self, file_id: bytes):
resp = self._session.api().send(
"GET", "/storage-resolve/files/audio/interactive/{}".format(
Utils.bytes_to_hex(file_id)), None, None)
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)
self._LOGGER.debug("Fetched CDN url for {}: {}".format(
Utils.bytes_to_hex(file_id), url))
return url
else:
raise CdnManager.CdnException(
"Could not retrieve CDN url! result: {}".format(proto.result))
class CdnException(Exception):
def __init__(self, ex):
super().__init__(ex)
class InternalResponse:
_buffer: bytearray
_headers: dict[str, str]
def __init__(self, buffer: bytearray, headers: dict[str, str]):
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)
token_str = str(token_query.get("__token__"))
if token_str != "None" and len(token_str) != 0:
expire_at = None
split = token_str.split("~")
for s in split:
try:
i = s[0].index("=")
except ValueError:
continue
if s[0][:i] == "exp":
expire_at = int(s[0][i:])
break
if expire_at is None:
self._expiration = -1
self._cdnManager._LOGGER.warning(
"Invalid __token__ in CDN url: {}".format(url))
return
self._expiration = expire_at * 1000
else:
try:
i = token_url.query.index("_")
except ValueError:
self._expiration = -1
self._cdnManager._LOGGER.warning(
"Couldn't extract expiration, invalid parameter in CDN url: "
.format(url))
return
self._expiration = int(token_url.query[:i]) * 1000
else:
self._expiration = -1
class Streamer(GeneralAudioStream, GeneralWritableStream):
_session: Session = None
_streamId: StreamId = None
_executorService = concurrent.futures.ThreadPoolExecutor()
_audioFormat: SuperAudioFormat = None
_audioDecrypt: AudioDecrypt = None
_cdnUrl = None
_size: int
_buffer: list[bytearray]
_available: list[bool]
_requested: list[bool]
_chunks: int
_internalStream: CdnManager.Streamer.InternalStream = None
_haltListener: HaltListener = None
def __init__(self, session: Session, stream_id: StreamId,
audio_format: SuperAudioFormat, cdn_url,
cache: CacheManager, audio_decrypt: AudioDecrypt,
halt_listener: HaltListener):
self._session = session
self._streamId = stream_id
self._audioFormat = audio_format
self._audioDecrypt = audio_decrypt
self._cdnUrl = cdn_url
self._haltListener = halt_listener
resp = self.request(range_start=0,
range_end=ChannelManager.CHUNK_SIZE - 1)
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])
self._chunks = int(
math.ceil(self._size / ChannelManager.CHUNK_SIZE))
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)]
self._internalStream = CdnManager.Streamer.InternalStream(
self, False)
self._requested[0] = True
self.write_chunk(first_chunk, 0, False)
def write_chunk(self, chunk: bytes, chunk_index: int,
cached: bool) -> None:
if self._internalStream.is_closed():
return
self._session._LOGGER.debug(
"Chunk {}/{} completed, cached: {}, stream: {}".format(
chunk_index, self._chunks, cached, self.describe()))
self._buffer[chunk_index] = self._audioDecrypt.decrypt_chunk(
chunk_index, chunk)
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():
return "episode_gid: {}".format(
self._streamId.get_episode_gid())
else:
return "file_id: {}".format(self._streamId.get_file_id())
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)
def request(self,
chunk: int = None,
range_start: int = None,
range_end: int = None) -> CdnManager.InternalResponse:
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
resp = self._session.client().get(self._cdnUrl._url,
headers={
"Range":
"bytes={}-{}".format(
range_start, range_end)
})
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)
def close(self) -> None:
super().close()
def buffer(self) -> list[bytearray]:
return self.streamer._buffer
def size(self) -> int:
return self.streamer._size
def requested_chunks(self) -> list[bool]:
return self.streamer._requested
def available_chunks(self) -> list[bool]:
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(
lambda: self.streamer.request_chunk(index))
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(
chunk, _time))
def stream_read_resumed(self, chunk: int, _time: int) -> None:
if self.streamer._haltListener is not None:
self.streamer._executorService.submit(
lambda: self.streamer._haltListener.
stream_read_resumed(chunk, _time))

View File

@@ -0,0 +1,2 @@
from librespot.audio.cdn.CdnFeedHelper import CdnFeedHelper
from librespot.audio.cdn.CdnManager import ChannelManager

View File

@@ -0,0 +1,49 @@
from Crypto.Cipher import AES
from Crypto.Util import Counter
from librespot.audio.storage.ChannelManager import ChannelManager
from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt
import time
class AesAudioDecrypt(AudioDecrypt):
audio_aes_iv = bytes([
0x72, 0xe0, 0x67, 0xfb, 0xdd, 0xcb, 0xcf, 0x77, 0xeb, 0xe8, 0xbc, 0x64,
0x3f, 0x63, 0x0d, 0x93
])
iv_int = int.from_bytes(audio_aes_iv, "big")
iv_diff = 0x100
cipher = None
decrypt_count = 0
decrypt_total_time = 0
key: bytes = None
def __init__(self, key: bytes):
self.key = key
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
new_buffer = b""
iv = self.iv_int + int(ChannelManager.CHUNK_SIZE * chunk_index / 16)
start = time.time_ns()
for i in range(0, len(buffer), 4096):
cipher = AES.new(key=self.key,
mode=AES.MODE_CTR,
counter=Counter.new(128, initial_value=iv))
count = min(4096, len(buffer) - i)
decrypted_buffer = cipher.decrypt(buffer[i:i + count])
new_buffer += decrypted_buffer
if count != len(decrypted_buffer):
raise RuntimeError(
"Couldn't process all data, actual: {}, expected: {}".
format(len(decrypted_buffer), count))
iv += self.iv_diff
self.decrypt_total_time += time.time_ns()
self.decrypt_count += 1
return new_buffer
def decrypt_time_ms(self):
return 0 if self.decrypt_count == 0 else int(
(self.decrypt_total_time / self.decrypt_count) / 1000000)

View File

@@ -0,0 +1,6 @@
class AudioDecrypt:
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
pass
def decrypt_time_ms(self):
pass

View File

@@ -0,0 +1,9 @@
from librespot.audio.decrypt import AudioDecrypt
class NoopAudioDecrypt(AudioDecrypt):
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
pass
def decrypt_time_ms(self):
return 0

View File

@@ -0,0 +1,3 @@
from librespot.audio.decrypt.AesAudioDecrypt import AesAudioDecrypt
from librespot.audio.decrypt.AudioDecrypt import AudioDecrypt
from librespot.audio.decrypt.NoopAudioDecrypt import NoopAudioDecrypt

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
import typing
if typing.TYPE_CHECKING:
from librespot.proto import Metadata
class AudioQualityPicker:
def get_file(self, files: list[Metadata.AudioFile]) -> Metadata.AudioFile:
pass

View File

@@ -0,0 +1,27 @@
from librespot.proto import Metadata
import enum
class SuperAudioFormat(enum.Enum):
MP3 = 0x00
VORBIS = 0x01
AAC = 0x02
@staticmethod
def get(audio_format: Metadata.AudioFile.Format):
if audio_format == Metadata.AudioFile.Format.OGG_VORBIS_96 or \
audio_format == Metadata.AudioFile.Format.OGG_VORBIS_160 or \
audio_format == Metadata.AudioFile.Format.OGG_VORBIS_320:
return SuperAudioFormat.VORBIS
elif audio_format == Metadata.AudioFile.Format.MP3_256 or \
audio_format == Metadata.AudioFile.Format.MP3_320 or \
audio_format == Metadata.AudioFile.Format.MP3_160 or \
audio_format == Metadata.AudioFile.Format.MP3_96 or \
audio_format == Metadata.AudioFile.Format.MP3_160_ENC:
return SuperAudioFormat.MP3
elif audio_format == Metadata.AudioFile.Format.AAC_24 or \
audio_format == Metadata.AudioFile.Format.AAC_48 or \
audio_format == Metadata.AudioFile.Format.AAC_24_NORM:
return SuperAudioFormat.AAC
else:
raise RuntimeError("Unknown audio format: {}".format(audio_format))

View File

@@ -0,0 +1,2 @@
from librespot.audio.format.AudioQualityPicker import AudioQualityPicker
from librespot.audio.format.SuperAudioFormat import SuperAudioFormat

View File

@@ -0,0 +1,12 @@
from librespot.audio.GeneralWritableStream import GeneralWritableStream
class AudioFile(GeneralWritableStream):
def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool):
pass
def write_header(self, chunk_id: int, b: bytearray, cached: bool):
pass
def stream_error(self, chunk_index: int, code: int):
pass

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
import typing
if typing.TYPE_CHECKING:
from librespot.core.Session import Session
class AudioFileStreaming:
cache_handler = None
def __init__(self, session: Session):
pass

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
import queue
from librespot.audio.storage import AudioFile
from librespot.common import Utils
from librespot.core import PacketsReceiver, Session
from librespot.crypto import Packet
from librespot.standard import BytesInputStream, BytesOutputStream, Closeable, Runnable
import concurrent.futures
import logging
import threading
class ChannelManager(Closeable, PacketsReceiver.PacketsReceiver):
CHUNK_SIZE: int = 128 * 1024
_LOGGER: logging = logging.getLogger(__name__)
_channels: dict[int, Channel] = dict()
_seqHolder: int = 0
_seqHolderLock: threading.Condition = threading.Condition()
_executorService: concurrent.futures.ThreadPoolExecutor = concurrent.futures.ThreadPoolExecutor(
)
_session: Session = None
def __init__(self, session: Session):
self._session = session
def request_chunk(self, file_id: bytes, index: int, file: AudioFile):
start = int(index * self.CHUNK_SIZE / 4)
end = int((index + 1) * self.CHUNK_SIZE / 4)
channel = ChannelManager.Channel(self, file, index)
self._channels[channel.chunkId] = channel
out = BytesOutputStream()
out.write_short(channel.chunkId)
out.write_int(0x00000000)
out.write_int(0x00000000)
out.write_int(0x00004e20)
out.write_int(0x00030d40)
out.write(file_id)
out.write_int(start)
out.write_int(end)
self._session.send(Packet.Type.stream_chunk, out.buffer)
def dispatch(self, packet: Packet) -> None:
payload = BytesInputStream(packet.payload)
if packet.is_cmd(Packet.Type.stream_chunk_res):
chunk_id = payload.read_short()
channel = self._channels.get(chunk_id)
if channel is None:
self._LOGGER.warning(
"Couldn't find channel, id: {}, received: {}".format(
chunk_id, len(packet.payload)))
return
channel._add_to_queue(payload)
elif packet.is_cmd(Packet.Type.channel_error):
chunk_id = payload.read_short()
channel = self._channels.get(chunk_id)
if channel is None:
self._LOGGER.warning(
"Dropping channel error, id: {}, code: {}".format(
chunk_id, payload.read_short()))
return
channel.stream_error(payload.read_short())
else:
self._LOGGER.warning(
"Couldn't handle packet, cmd: {}, payload: {}".format(
packet.cmd, Utils.Utils.bytes_to_hex(packet.payload)))
def close(self) -> None:
self._executorService.shutdown()
class Channel:
_channelManager: ChannelManager
chunkId: int
_q: queue.Queue = queue.Queue()
_file: AudioFile
_chunkIndex: int
_buffer: BytesOutputStream = BytesOutputStream()
_header: bool = True
def __init__(self, channel_manager: ChannelManager, file: AudioFile,
chunk_index: int):
self._channelManager = channel_manager
self._file = file
self._chunkIndex = chunk_index
with self._channelManager._seqHolderLock:
self.chunkId = self._channelManager._seqHolder
self._channelManager._seqHolder += 1
self._channelManager._executorService.submit(
lambda: ChannelManager.Channel.Handler(self))
def _handle(self, payload: BytesInputStream) -> bool:
if len(payload.buffer) == 0:
if not self._header:
self._file.write_chunk(bytearray(payload.buffer),
self._chunkIndex, False)
return True
self._channelManager._LOGGER.debug(
"Received empty chunk, skipping.")
return False
if self._header:
length: int
while len(payload.buffer) > 0:
length = payload.read_short()
if not length > 0:
break
header_id = payload.read_byte()
header_data = payload.read(length - 1)
self._file.write_header(int.from_bytes(header_id, "big"),
bytearray(header_data), False)
self._header = False
else:
self._buffer.write(payload.read(len(payload.buffer)))
return False
def _add_to_queue(self, payload):
self._q.put(payload)
def stream_error(self, code: int) -> None:
self._file.stream_error(self._chunkIndex, code)
class Handler(Runnable):
_channel: ChannelManager.Channel = None
def __init__(self, channel: ChannelManager.Channel):
self._channel = channel
def run(self) -> None:
self._channel._channelManager._LOGGER.debug(
"ChannelManager.Handler is starting")
with self._channel._q.all_tasks_done:
self._channel._channelManager._channels.pop(
self._channel.chunkId)
self._channel._channelManager._LOGGER.debug(
"ChannelManager.Handler is shutting down")

View File

@@ -0,0 +1,3 @@
from librespot.audio.storage.AudioFile import AudioFile
from librespot.audio.storage.AudioFileStreaming import AudioFileStreaming
from librespot.audio.storage.ChannelManager import ChannelManager