Change Directory
This commit is contained in:
231
librespot/audio/AbsChunkedInputStream.py
Normal file
231
librespot/audio/AbsChunkedInputStream.py
Normal 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))
|
||||
112
librespot/audio/AudioKeyManager.py
Normal file
112
librespot/audio/AudioKeyManager.py
Normal 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)
|
||||
20
librespot/audio/GeneralAudioStream.py
Normal file
20
librespot/audio/GeneralAudioStream.py
Normal 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
|
||||
3
librespot/audio/GeneralWritableStream.py
Normal file
3
librespot/audio/GeneralWritableStream.py
Normal file
@@ -0,0 +1,3 @@
|
||||
class GeneralWritableStream:
|
||||
def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool):
|
||||
pass
|
||||
6
librespot/audio/HaltListener.py
Normal file
6
librespot/audio/HaltListener.py
Normal 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
|
||||
50
librespot/audio/NormalizationData.py
Normal file
50
librespot/audio/NormalizationData.py
Normal 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
|
||||
141
librespot/audio/PlayableContentFeeder.py
Normal file
141
librespot/audio/PlayableContentFeeder.py
Normal 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()
|
||||
30
librespot/audio/StreamId.py
Normal file
30
librespot/audio/StreamId.py
Normal 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)
|
||||
8
librespot/audio/__init__.py
Normal file
8
librespot/audio/__init__.py
Normal 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
|
||||
85
librespot/audio/cdn/CdnFeedHelper.py
Normal file
85
librespot/audio/cdn/CdnFeedHelper.py
Normal 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))
|
||||
307
librespot/audio/cdn/CdnManager.py
Normal file
307
librespot/audio/cdn/CdnManager.py
Normal 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))
|
||||
2
librespot/audio/cdn/__init__.py
Normal file
2
librespot/audio/cdn/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from librespot.audio.cdn.CdnFeedHelper import CdnFeedHelper
|
||||
from librespot.audio.cdn.CdnManager import ChannelManager
|
||||
49
librespot/audio/decrypt/AesAudioDecrypt.py
Normal file
49
librespot/audio/decrypt/AesAudioDecrypt.py
Normal 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)
|
||||
6
librespot/audio/decrypt/AudioDecrypt.py
Normal file
6
librespot/audio/decrypt/AudioDecrypt.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class AudioDecrypt:
|
||||
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
|
||||
pass
|
||||
|
||||
def decrypt_time_ms(self):
|
||||
pass
|
||||
9
librespot/audio/decrypt/NoopAudioDecrypt.py
Normal file
9
librespot/audio/decrypt/NoopAudioDecrypt.py
Normal 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
|
||||
3
librespot/audio/decrypt/__init__.py
Normal file
3
librespot/audio/decrypt/__init__.py
Normal 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
|
||||
10
librespot/audio/format/AudioQualityPicker.py
Normal file
10
librespot/audio/format/AudioQualityPicker.py
Normal 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
|
||||
27
librespot/audio/format/SuperAudioFormat.py
Normal file
27
librespot/audio/format/SuperAudioFormat.py
Normal 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))
|
||||
2
librespot/audio/format/__init__.py
Normal file
2
librespot/audio/format/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from librespot.audio.format.AudioQualityPicker import AudioQualityPicker
|
||||
from librespot.audio.format.SuperAudioFormat import SuperAudioFormat
|
||||
12
librespot/audio/storage/AudioFile.py
Normal file
12
librespot/audio/storage/AudioFile.py
Normal 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
|
||||
12
librespot/audio/storage/AudioFileStreaming.py
Normal file
12
librespot/audio/storage/AudioFileStreaming.py
Normal 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
|
||||
146
librespot/audio/storage/ChannelManager.py
Normal file
146
librespot/audio/storage/ChannelManager.py
Normal 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")
|
||||
3
librespot/audio/storage/__init__.py
Normal file
3
librespot/audio/storage/__init__.py
Normal 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
|
||||
Reference in New Issue
Block a user