Initial Commit
This commit is contained in:
parent
6bee6cd53a
commit
01dae8ada4
|
@ -20,7 +20,6 @@ parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
pip-wheel-metadata/
|
|
||||||
share/python-wheels/
|
share/python-wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
|
@ -50,6 +49,7 @@ coverage.xml
|
||||||
*.py,cover
|
*.py,cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
@ -72,6 +72,7 @@ instance/
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
|
@ -82,7 +83,9 @@ profile_default/
|
||||||
ipython_config.py
|
ipython_config.py
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
# pipenv
|
# pipenv
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
@ -127,3 +130,12 @@ dmypy.json
|
||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# for PyCharm
|
||||||
|
.idea/
|
||||||
|
|
|
@ -186,7 +186,7 @@
|
||||||
same "printed page" as the copyright notice for easier
|
same "printed page" as the copyright notice for easier
|
||||||
identification within third-party archives.
|
identification within third-party archives.
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
Copyright 2021 kokarare1212
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
24
README.md
24
README.md
|
@ -1,2 +1,24 @@
|
||||||
# librespot-python
|
![License](https://img.shields.io/github/license/kokarare1212/librespot-python.svg?style=for-the-badge)
|
||||||
|
![Stars](https://img.shields.io/github/stars/kokarare1212/librespot-python.svg?style=for-the-badge)
|
||||||
|
![Forks](https://img.shields.io/github/forks/kokarare1212/librespot-python.svg?style=for-the-badge)
|
||||||
|
# Librespot-Python
|
||||||
Open Source Spotify Client
|
Open Source Spotify Client
|
||||||
|
## About The Project
|
||||||
|
This project was developed to make the music streaming service Spotify available on any device.
|
||||||
|
## Note
|
||||||
|
We **DO NOT** encourage piracy and **DO NOT** support any form of downloader/recorder designed with the help of this repository and in general anything that goes against the Spotify ToS.
|
||||||
|
## Getting Started
|
||||||
|
### Prerequisites
|
||||||
|
* [Python](https://python.org/)
|
||||||
|
## Usage
|
||||||
|
Coming soon
|
||||||
|
## Roadmap
|
||||||
|
Coming soon
|
||||||
|
## License
|
||||||
|
Distributed under the Apache-2.0 License. See `LICENSE.txt` for more information.
|
||||||
|
## Related Projects
|
||||||
|
* [Librespot](https://github.com/librespot-org/librespot) (Concept)
|
||||||
|
* [Librespot-Java](https://github.com/librespot-org/librespot-java) (Core)
|
||||||
|
* [PySpotify2](https://github.com/pymedia/pyspotify2) (Shannon)
|
||||||
|
## Special thanks
|
||||||
|
Coming soon
|
|
@ -0,0 +1,98 @@
|
||||||
|
from librespot.core import Session
|
||||||
|
from librespot.metadata import TrackId
|
||||||
|
from librespot.player.codecs import AudioQuality, VorbisOnlyAudioQuality
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import requests
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
def streaming(session: Session, uri: str, quality: AudioQuality):
|
||||||
|
stream = session.content_feeder().load(TrackId.from_uri(uri), VorbisOnlyAudioQuality(quality), False, None)
|
||||||
|
process = subprocess.Popen(
|
||||||
|
["ffplay", "-autoexit", "-nodisp", "-loglevel", "quiet", "-"],
|
||||||
|
stdin=subprocess.PIPE)
|
||||||
|
while True:
|
||||||
|
i = stream.input_stream.stream().read()
|
||||||
|
if i == -1:
|
||||||
|
process.kill()
|
||||||
|
break
|
||||||
|
process.stdin.write(bytes([i]))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
session = Session.Builder().stored().create()
|
||||||
|
|
||||||
|
quality: AudioQuality = AudioQuality.AudioQuality.VERY_HIGH
|
||||||
|
running: bool = True
|
||||||
|
while running:
|
||||||
|
try:
|
||||||
|
command = input("Librespot >>> ")
|
||||||
|
argv = command.split(" ")
|
||||||
|
|
||||||
|
if argv[0] == "clear" or argv[0] == "cls":
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
os.system("cls")
|
||||||
|
else:
|
||||||
|
os.system("clear")
|
||||||
|
continue
|
||||||
|
elif argv[0] == "play" or argv[0] == "p":
|
||||||
|
try:
|
||||||
|
track = argv[1]
|
||||||
|
except IndexError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
streaming(session, track, quality)
|
||||||
|
continue
|
||||||
|
elif argv[0] == "search" or argv[0] == "s":
|
||||||
|
try:
|
||||||
|
keyword = argv[1]
|
||||||
|
except IndexError:
|
||||||
|
continue
|
||||||
|
access_token = session.tokens().get("playlist-read")
|
||||||
|
resp = requests.get("https://api.spotify.com/v1/search?type=track&limit=5&q={}".format(keyword), headers={
|
||||||
|
"Authorization": "Bearer {}".format(access_token),
|
||||||
|
"Accept-Language": "ja"
|
||||||
|
})
|
||||||
|
obj = resp.json()
|
||||||
|
i = 1
|
||||||
|
for track in obj["tracks"]["items"]:
|
||||||
|
print("{}, {} - {}".format(i, track["name"], ",".join([artist["name"] for artist in track["artists"]])))
|
||||||
|
i += 1
|
||||||
|
user_str = input("SELECT (1~5) | Librespot >>> ")
|
||||||
|
try:
|
||||||
|
selected_number = int(user_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if selected_number not in range(1, 6):
|
||||||
|
continue
|
||||||
|
streaming(session, obj["tracks"]["items"][selected_number - 1]["uri"], quality)
|
||||||
|
elif argv[0] == "quality":
|
||||||
|
try:
|
||||||
|
new_quality = argv[1]
|
||||||
|
except IndexError:
|
||||||
|
print("Current Quality: {}".format(quality))
|
||||||
|
continue
|
||||||
|
if new_quality.lower() == "very_high":
|
||||||
|
quality = AudioQuality.AudioQuality.VERY_HIGH
|
||||||
|
elif new_quality.lower() == "high":
|
||||||
|
quality = AudioQuality.AudioQuality.HIGH
|
||||||
|
elif new_quality.lower() == "normal":
|
||||||
|
quality = AudioQuality.AudioQuality.NORMAL
|
||||||
|
else:
|
||||||
|
print("Unsupported Quality: {}".format(new_quality))
|
||||||
|
continue
|
||||||
|
elif argv[0] == "quit" or argv[0] == "q" or argv[0] == "exit":
|
||||||
|
running = False
|
||||||
|
session.close()
|
||||||
|
continue
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
running = False
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -0,0 +1,168 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify;
|
||||||
|
option java_package = "com.spotify";
|
||||||
|
|
||||||
|
message ClientResponseEncrypted {
|
||||||
|
required LoginCredentials login_credentials = 0xa;
|
||||||
|
optional AccountCreation account_creation = 0x14;
|
||||||
|
optional FingerprintResponseUnion fingerprint_response = 0x1e;
|
||||||
|
optional PeerTicketUnion peer_ticket = 0x28;
|
||||||
|
required SystemInfo system_info = 0x32;
|
||||||
|
optional string platform_model = 0x3c;
|
||||||
|
optional string version_string = 0x46;
|
||||||
|
optional LibspotifyAppKey appkey = 0x50;
|
||||||
|
optional ClientInfo client_info = 0x5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginCredentials {
|
||||||
|
optional string username = 0xa;
|
||||||
|
required AuthenticationType typ = 0x14;
|
||||||
|
optional bytes auth_data = 0x1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthenticationType {
|
||||||
|
AUTHENTICATION_USER_PASS = 0x0;
|
||||||
|
AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS = 0x1;
|
||||||
|
AUTHENTICATION_STORED_FACEBOOK_CREDENTIALS = 0x2;
|
||||||
|
AUTHENTICATION_SPOTIFY_TOKEN = 0x3;
|
||||||
|
AUTHENTICATION_FACEBOOK_TOKEN = 0x4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountCreation {
|
||||||
|
ACCOUNT_CREATION_ALWAYS_PROMPT = 0x1;
|
||||||
|
ACCOUNT_CREATION_ALWAYS_CREATE = 0x3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FingerprintResponseUnion {
|
||||||
|
optional FingerprintGrainResponse grain = 0xa;
|
||||||
|
optional FingerprintHmacRipemdResponse hmac_ripemd = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FingerprintGrainResponse {
|
||||||
|
required bytes encrypted_key = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FingerprintHmacRipemdResponse {
|
||||||
|
required bytes hmac = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PeerTicketUnion {
|
||||||
|
optional PeerTicketPublicKey public_key = 0xa;
|
||||||
|
optional PeerTicketOld old_ticket = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PeerTicketPublicKey {
|
||||||
|
required bytes public_key = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PeerTicketOld {
|
||||||
|
required bytes peer_ticket = 0xa;
|
||||||
|
required bytes peer_ticket_signature = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SystemInfo {
|
||||||
|
required CpuFamily cpu_family = 0xa;
|
||||||
|
optional uint32 cpu_subtype = 0x14;
|
||||||
|
optional uint32 cpu_ext = 0x1e;
|
||||||
|
optional Brand brand = 0x28;
|
||||||
|
optional uint32 brand_flags = 0x32;
|
||||||
|
required Os os = 0x3c;
|
||||||
|
optional uint32 os_version = 0x46;
|
||||||
|
optional uint32 os_ext = 0x50;
|
||||||
|
optional string system_information_string = 0x5a;
|
||||||
|
optional string device_id = 0x64;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CpuFamily {
|
||||||
|
CPU_UNKNOWN = 0x0;
|
||||||
|
CPU_X86 = 0x1;
|
||||||
|
CPU_X86_64 = 0x2;
|
||||||
|
CPU_PPC = 0x3;
|
||||||
|
CPU_PPC_64 = 0x4;
|
||||||
|
CPU_ARM = 0x5;
|
||||||
|
CPU_IA64 = 0x6;
|
||||||
|
CPU_SH = 0x7;
|
||||||
|
CPU_MIPS = 0x8;
|
||||||
|
CPU_BLACKFIN = 0x9;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Brand {
|
||||||
|
BRAND_UNBRANDED = 0x0;
|
||||||
|
BRAND_INQ = 0x1;
|
||||||
|
BRAND_HTC = 0x2;
|
||||||
|
BRAND_NOKIA = 0x3;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Os {
|
||||||
|
OS_UNKNOWN = 0x0;
|
||||||
|
OS_WINDOWS = 0x1;
|
||||||
|
OS_OSX = 0x2;
|
||||||
|
OS_IPHONE = 0x3;
|
||||||
|
OS_S60 = 0x4;
|
||||||
|
OS_LINUX = 0x5;
|
||||||
|
OS_WINDOWS_CE = 0x6;
|
||||||
|
OS_ANDROID = 0x7;
|
||||||
|
OS_PALM = 0x8;
|
||||||
|
OS_FREEBSD = 0x9;
|
||||||
|
OS_BLACKBERRY = 0xa;
|
||||||
|
OS_SONOS = 0xb;
|
||||||
|
OS_LOGITECH = 0xc;
|
||||||
|
OS_WP7 = 0xd;
|
||||||
|
OS_ONKYO = 0xe;
|
||||||
|
OS_PHILIPS = 0xf;
|
||||||
|
OS_WD = 0x10;
|
||||||
|
OS_VOLVO = 0x11;
|
||||||
|
OS_TIVO = 0x12;
|
||||||
|
OS_AWOX = 0x13;
|
||||||
|
OS_MEEGO = 0x14;
|
||||||
|
OS_QNXNTO = 0x15;
|
||||||
|
OS_BCO = 0x16;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LibspotifyAppKey {
|
||||||
|
required uint32 version = 0x1;
|
||||||
|
required bytes devkey = 0x2;
|
||||||
|
required bytes signature = 0x3;
|
||||||
|
required string useragent = 0x4;
|
||||||
|
required bytes callback_hash = 0x5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClientInfo {
|
||||||
|
optional bool limited = 0x1;
|
||||||
|
optional ClientInfoFacebook fb = 0x2;
|
||||||
|
optional string language = 0x3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClientInfoFacebook {
|
||||||
|
optional string machine_id = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message APWelcome {
|
||||||
|
required string canonical_username = 0xa;
|
||||||
|
required AccountType account_type_logged_in = 0x14;
|
||||||
|
required AccountType credentials_type_logged_in = 0x19;
|
||||||
|
required AuthenticationType reusable_auth_credentials_type = 0x1e;
|
||||||
|
required bytes reusable_auth_credentials = 0x28;
|
||||||
|
optional bytes lfs_secret = 0x32;
|
||||||
|
optional AccountInfo account_info = 0x3c;
|
||||||
|
optional AccountInfoFacebook fb = 0x46;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountType {
|
||||||
|
Spotify = 0x0;
|
||||||
|
Facebook = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AccountInfo {
|
||||||
|
optional AccountInfoSpotify spotify = 0x1;
|
||||||
|
optional AccountInfoFacebook facebook = 0x2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AccountInfoSpotify {
|
||||||
|
}
|
||||||
|
|
||||||
|
message AccountInfoFacebook {
|
||||||
|
optional string access_token = 0x1;
|
||||||
|
optional string machine_id = 0x2;
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package com.spotify.canvaz;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.canvaz";
|
||||||
|
|
||||||
|
enum Type {
|
||||||
|
IMAGE = 0;
|
||||||
|
VIDEO = 1;
|
||||||
|
VIDEO_LOOPING = 2;
|
||||||
|
VIDEO_LOOPING_RANDOM = 3;
|
||||||
|
GIF = 4;
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package com.spotify.canvazcache;
|
||||||
|
|
||||||
|
import "canvaz-meta.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.canvaz";
|
||||||
|
|
||||||
|
message Artist {
|
||||||
|
string uri = 1;
|
||||||
|
string name = 2;
|
||||||
|
string avatar = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EntityCanvazResponse {
|
||||||
|
repeated Canvaz canvases = 1;
|
||||||
|
message Canvaz {
|
||||||
|
string id = 1;
|
||||||
|
string url = 2;
|
||||||
|
string file_id = 3;
|
||||||
|
canvaz.Type type = 4;
|
||||||
|
string entity_uri = 5;
|
||||||
|
Artist artist = 6;
|
||||||
|
bool explicit = 7;
|
||||||
|
string uploaded_by = 8;
|
||||||
|
string etag = 9;
|
||||||
|
string canvas_uri = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
int64 ttl_in_seconds = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EntityCanvazRequest {
|
||||||
|
repeated Entity entities = 1;
|
||||||
|
message Entity {
|
||||||
|
string entity_uri = 1;
|
||||||
|
string etag = 2;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,173 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package connectstate;
|
||||||
|
import "player.proto";
|
||||||
|
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.connectstate";
|
||||||
|
|
||||||
|
message ClusterUpdate {
|
||||||
|
Cluster cluster = 1;
|
||||||
|
ClusterUpdateReason update_reason = 2;
|
||||||
|
string ack_id = 3;
|
||||||
|
repeated string devices_that_changed = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Device {
|
||||||
|
DeviceInfo device_info = 1;
|
||||||
|
PlayerState player_state = 2;
|
||||||
|
PrivateDeviceInfo private_device_info = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Cluster {
|
||||||
|
int64 timestamp = 1;
|
||||||
|
string active_device_id = 2;
|
||||||
|
PlayerState player_state = 3;
|
||||||
|
map<string, DeviceInfo> device = 4;
|
||||||
|
bytes transfer_data = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PutStateRequest {
|
||||||
|
string callback_url = 1;
|
||||||
|
Device device = 2;
|
||||||
|
MemberType member_type = 3;
|
||||||
|
bool is_active = 4;
|
||||||
|
PutStateReason put_state_reason = 5;
|
||||||
|
uint32 message_id = 6;
|
||||||
|
string last_command_sent_by_device_id = 7;
|
||||||
|
uint32 last_command_message_id = 8;
|
||||||
|
uint64 started_playing_at = 9;
|
||||||
|
uint64 has_been_playing_for_ms = 11;
|
||||||
|
uint64 client_side_timestamp = 12;
|
||||||
|
bool only_write_player_state = 13;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PrivateDeviceInfo {
|
||||||
|
string platform = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeRequest {
|
||||||
|
string callback_url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceInfo {
|
||||||
|
bool can_play = 1;
|
||||||
|
uint32 volume = 2;
|
||||||
|
string name = 3;
|
||||||
|
Capabilities capabilities = 4;
|
||||||
|
string device_software_version = 6;
|
||||||
|
DeviceType device_type = 7;
|
||||||
|
string spirc_version = 9;
|
||||||
|
string device_id = 10;
|
||||||
|
bool is_private_session = 11;
|
||||||
|
bool is_social_connect = 12;
|
||||||
|
string client_id = 13;
|
||||||
|
string brand = 14;
|
||||||
|
string model = 15;
|
||||||
|
map<string, string> metadata_map = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Capabilities {
|
||||||
|
bool can_be_player = 2;
|
||||||
|
bool restrict_to_local = 3;
|
||||||
|
bool gaia_eq_connect_id = 5;
|
||||||
|
bool supports_logout = 6;
|
||||||
|
bool is_observable = 7;
|
||||||
|
int32 volume_steps = 8;
|
||||||
|
repeated string supported_types = 9;
|
||||||
|
bool command_acks = 10;
|
||||||
|
bool supports_rename = 11;
|
||||||
|
bool hidden = 12;
|
||||||
|
bool disable_volume = 13;
|
||||||
|
bool connect_disabled = 14;
|
||||||
|
bool supports_playlist_v2 = 15;
|
||||||
|
bool is_controllable = 16;
|
||||||
|
bool supports_external_episodes = 17;
|
||||||
|
bool supports_set_backend_metadata = 18;
|
||||||
|
bool supports_transfer_command = 19;
|
||||||
|
bool supports_command_request = 20;
|
||||||
|
bool is_voice_enabled = 21;
|
||||||
|
bool needs_full_player_state = 22;
|
||||||
|
bool supports_gzip_pushes = 23;
|
||||||
|
// reserved 1, "supported_contexts";
|
||||||
|
}
|
||||||
|
|
||||||
|
message ConnectCommandOptions {
|
||||||
|
int32 message_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LogoutCommand {
|
||||||
|
ConnectCommandOptions command_options = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetVolumeCommand {
|
||||||
|
int32 volume = 1;
|
||||||
|
ConnectCommandOptions command_options = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RenameCommand {
|
||||||
|
string rename_to = 1;
|
||||||
|
ConnectCommandOptions command_options = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetBackendMetadataCommand {
|
||||||
|
map<string, string> metadata = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SendCommandResult {
|
||||||
|
UNKNOWN_SEND_COMMAND_RESULT = 0;
|
||||||
|
SUCCESS = 1;
|
||||||
|
DEVICE_NOT_FOUND = 2;
|
||||||
|
CONTEXT_PLAYER_ERROR = 3;
|
||||||
|
DEVICE_DISAPPEARED = 4;
|
||||||
|
UPSTREAM_ERROR = 5;
|
||||||
|
DEVICE_DOES_NOT_SUPPORT_COMMAND = 6;
|
||||||
|
RATE_LIMITED = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PutStateReason {
|
||||||
|
UNKNOWN_PUT_STATE_REASON = 0;
|
||||||
|
SPIRC_HELLO = 1;
|
||||||
|
SPIRC_NOTIFY = 2;
|
||||||
|
NEW_DEVICE = 3;
|
||||||
|
PLAYER_STATE_CHANGED = 4;
|
||||||
|
VOLUME_CHANGED = 5;
|
||||||
|
PICKER_OPENED = 6;
|
||||||
|
BECAME_INACTIVE = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemberType {
|
||||||
|
SPIRC_V2 = 0;
|
||||||
|
SPIRC_V3 = 1;
|
||||||
|
CONNECT_STATE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ClusterUpdateReason {
|
||||||
|
UNKNOWN_CLUSTER_UPDATE_REASON = 0;
|
||||||
|
DEVICES_DISAPPEARED = 1;
|
||||||
|
DEVICE_STATE_CHANGED = 2;
|
||||||
|
NEW_DEVICE_APPEARED = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceType {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
COMPUTER = 1;
|
||||||
|
TABLET = 2;
|
||||||
|
SMARTPHONE = 3;
|
||||||
|
SPEAKER = 4;
|
||||||
|
TV = 5;
|
||||||
|
AVR = 6;
|
||||||
|
STB = 7;
|
||||||
|
AUDIO_DONGLE = 8;
|
||||||
|
GAME_CONSOLE = 9;
|
||||||
|
CAST_VIDEO = 10;
|
||||||
|
CAST_AUDIO = 11;
|
||||||
|
AUTOMOBILE = 12;
|
||||||
|
SMARTWATCH = 13;
|
||||||
|
CHROMEBOOK = 14;
|
||||||
|
UNKNOWN_SPOTIFY = 100;
|
||||||
|
CAR_THING = 101;
|
||||||
|
OBSERVER = 102;
|
||||||
|
HOME_THING = 103;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto;
|
||||||
|
|
||||||
|
import "context_page.proto";
|
||||||
|
import "restrictions.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.context";
|
||||||
|
|
||||||
|
message Context {
|
||||||
|
optional string uri = 1;
|
||||||
|
optional string url = 2;
|
||||||
|
map<string, string> metadata = 3;
|
||||||
|
optional Restrictions restrictions = 4;
|
||||||
|
repeated ContextPage pages = 5;
|
||||||
|
optional bool loading = 6;
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto;
|
||||||
|
|
||||||
|
import "context_track.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.context";
|
||||||
|
|
||||||
|
message ContextPage {
|
||||||
|
optional string page_url = 1;
|
||||||
|
optional string next_page_url = 2;
|
||||||
|
map<string, string> metadata = 3;
|
||||||
|
repeated ContextTrack tracks = 4;
|
||||||
|
optional bool loading = 5;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.context";
|
||||||
|
|
||||||
|
message ContextPlayerOptions {
|
||||||
|
optional bool shuffling_context = 1;
|
||||||
|
optional bool repeating_context = 2;
|
||||||
|
optional bool repeating_track = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContextPlayerOptionOverrides {
|
||||||
|
optional bool shuffling_context = 1;
|
||||||
|
optional bool repeating_context = 2;
|
||||||
|
optional bool repeating_track = 3;
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.context";
|
||||||
|
|
||||||
|
message ContextTrack {
|
||||||
|
optional string uri = 1;
|
||||||
|
optional string uid = 2;
|
||||||
|
optional bytes gid = 3;
|
||||||
|
map<string, string> metadata = 4;
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.explicit_content.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.explicit";
|
||||||
|
|
||||||
|
message KeyValuePair {
|
||||||
|
required string key = 1;
|
||||||
|
required string value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserAttributesUpdate {
|
||||||
|
repeated KeyValuePair pairs = 1;
|
||||||
|
}
|
|
@ -0,0 +1,231 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify;
|
||||||
|
option java_package = "com.spotify";
|
||||||
|
|
||||||
|
message ClientHello {
|
||||||
|
required BuildInfo build_info = 0xa;
|
||||||
|
repeated Fingerprint fingerprints_supported = 0x14;
|
||||||
|
repeated Cryptosuite cryptosuites_supported = 0x1e;
|
||||||
|
repeated Powscheme powschemes_supported = 0x28;
|
||||||
|
required LoginCryptoHelloUnion login_crypto_hello = 0x32;
|
||||||
|
required bytes client_nonce = 0x3c;
|
||||||
|
optional bytes padding = 0x46;
|
||||||
|
optional FeatureSet feature_set = 0x50;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message BuildInfo {
|
||||||
|
required Product product = 0xa;
|
||||||
|
repeated ProductFlags product_flags = 0x14;
|
||||||
|
required Platform platform = 0x1e;
|
||||||
|
required uint64 version = 0x28;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Product {
|
||||||
|
PRODUCT_CLIENT = 0x0;
|
||||||
|
PRODUCT_LIBSPOTIFY = 0x1;
|
||||||
|
PRODUCT_MOBILE = 0x2;
|
||||||
|
PRODUCT_PARTNER = 0x3;
|
||||||
|
PRODUCT_LIBSPOTIFY_EMBEDDED = 0x5;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProductFlags {
|
||||||
|
PRODUCT_FLAG_NONE = 0x0;
|
||||||
|
PRODUCT_FLAG_DEV_BUILD = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Platform {
|
||||||
|
PLATFORM_WIN32_X86 = 0x0;
|
||||||
|
PLATFORM_OSX_X86 = 0x1;
|
||||||
|
PLATFORM_LINUX_X86 = 0x2;
|
||||||
|
PLATFORM_IPHONE_ARM = 0x3;
|
||||||
|
PLATFORM_S60_ARM = 0x4;
|
||||||
|
PLATFORM_OSX_PPC = 0x5;
|
||||||
|
PLATFORM_ANDROID_ARM = 0x6;
|
||||||
|
PLATFORM_WINDOWS_CE_ARM = 0x7;
|
||||||
|
PLATFORM_LINUX_X86_64 = 0x8;
|
||||||
|
PLATFORM_OSX_X86_64 = 0x9;
|
||||||
|
PLATFORM_PALM_ARM = 0xa;
|
||||||
|
PLATFORM_LINUX_SH = 0xb;
|
||||||
|
PLATFORM_FREEBSD_X86 = 0xc;
|
||||||
|
PLATFORM_FREEBSD_X86_64 = 0xd;
|
||||||
|
PLATFORM_BLACKBERRY_ARM = 0xe;
|
||||||
|
PLATFORM_SONOS = 0xf;
|
||||||
|
PLATFORM_LINUX_MIPS = 0x10;
|
||||||
|
PLATFORM_LINUX_ARM = 0x11;
|
||||||
|
PLATFORM_LOGITECH_ARM = 0x12;
|
||||||
|
PLATFORM_LINUX_BLACKFIN = 0x13;
|
||||||
|
PLATFORM_WP7_ARM = 0x14;
|
||||||
|
PLATFORM_ONKYO_ARM = 0x15;
|
||||||
|
PLATFORM_QNXNTO_ARM = 0x16;
|
||||||
|
PLATFORM_BCO_ARM = 0x17;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Fingerprint {
|
||||||
|
FINGERPRINT_GRAIN = 0x0;
|
||||||
|
FINGERPRINT_HMAC_RIPEMD = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Cryptosuite {
|
||||||
|
CRYPTO_SUITE_SHANNON = 0x0;
|
||||||
|
CRYPTO_SUITE_RC4_SHA1_HMAC = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Powscheme {
|
||||||
|
POW_HASH_CASH = 0x0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LoginCryptoHelloUnion {
|
||||||
|
optional LoginCryptoDiffieHellmanHello diffie_hellman = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LoginCryptoDiffieHellmanHello {
|
||||||
|
required bytes gc = 0xa;
|
||||||
|
required uint32 server_keys_known = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FeatureSet {
|
||||||
|
optional bool autoupdate2 = 0x1;
|
||||||
|
optional bool current_location = 0x2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message APResponseMessage {
|
||||||
|
optional APChallenge challenge = 0xa;
|
||||||
|
optional UpgradeRequiredMessage upgrade = 0x14;
|
||||||
|
optional APLoginFailed login_failed = 0x1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
message APChallenge {
|
||||||
|
required LoginCryptoChallengeUnion login_crypto_challenge = 0xa;
|
||||||
|
required FingerprintChallengeUnion fingerprint_challenge = 0x14;
|
||||||
|
required PoWChallengeUnion pow_challenge = 0x1e;
|
||||||
|
required CryptoChallengeUnion crypto_challenge = 0x28;
|
||||||
|
required bytes server_nonce = 0x32;
|
||||||
|
optional bytes padding = 0x3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginCryptoChallengeUnion {
|
||||||
|
optional LoginCryptoDiffieHellmanChallenge diffie_hellman = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginCryptoDiffieHellmanChallenge {
|
||||||
|
required bytes gs = 0xa;
|
||||||
|
required int32 server_signature_key = 0x14;
|
||||||
|
required bytes gs_signature = 0x1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FingerprintChallengeUnion {
|
||||||
|
optional FingerprintGrainChallenge grain = 0xa;
|
||||||
|
optional FingerprintHmacRipemdChallenge hmac_ripemd = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FingerprintGrainChallenge {
|
||||||
|
required bytes kek = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message FingerprintHmacRipemdChallenge {
|
||||||
|
required bytes challenge = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message PoWChallengeUnion {
|
||||||
|
optional PoWHashCashChallenge hash_cash = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PoWHashCashChallenge {
|
||||||
|
optional bytes prefix = 0xa;
|
||||||
|
optional int32 length = 0x14;
|
||||||
|
optional int32 target = 0x1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CryptoChallengeUnion {
|
||||||
|
optional CryptoShannonChallenge shannon = 0xa;
|
||||||
|
optional CryptoRc4Sha1HmacChallenge rc4_sha1_hmac = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CryptoShannonChallenge {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CryptoRc4Sha1HmacChallenge {
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message UpgradeRequiredMessage {
|
||||||
|
required bytes upgrade_signed_part = 0xa;
|
||||||
|
required bytes signature = 0x14;
|
||||||
|
optional string http_suffix = 0x1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
message APLoginFailed {
|
||||||
|
required ErrorCode error_code = 0xa;
|
||||||
|
optional int32 retry_delay = 0x14;
|
||||||
|
optional int32 expiry = 0x1e;
|
||||||
|
optional string error_description = 0x28;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ErrorCode {
|
||||||
|
ProtocolError = 0x0;
|
||||||
|
TryAnotherAP = 0x2;
|
||||||
|
BadConnectionId = 0x5;
|
||||||
|
TravelRestriction = 0x9;
|
||||||
|
PremiumAccountRequired = 0xb;
|
||||||
|
BadCredentials = 0xc;
|
||||||
|
CouldNotValidateCredentials = 0xd;
|
||||||
|
AccountExists = 0xe;
|
||||||
|
ExtraVerificationRequired = 0xf;
|
||||||
|
InvalidAppKey = 0x10;
|
||||||
|
ApplicationBanned = 0x11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClientResponsePlaintext {
|
||||||
|
required LoginCryptoResponseUnion login_crypto_response = 0xa;
|
||||||
|
required PoWResponseUnion pow_response = 0x14;
|
||||||
|
required CryptoResponseUnion crypto_response = 0x1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LoginCryptoResponseUnion {
|
||||||
|
optional LoginCryptoDiffieHellmanResponse diffie_hellman = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LoginCryptoDiffieHellmanResponse {
|
||||||
|
required bytes hmac = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message PoWResponseUnion {
|
||||||
|
optional PoWHashCashResponse hash_cash = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message PoWHashCashResponse {
|
||||||
|
required bytes hash_suffix = 0xa;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CryptoResponseUnion {
|
||||||
|
optional CryptoShannonResponse shannon = 0xa;
|
||||||
|
optional CryptoRc4Sha1HmacResponse rc4_sha1_hmac = 0x14;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CryptoShannonResponse {
|
||||||
|
optional int32 dummy = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message CryptoRc4Sha1HmacResponse {
|
||||||
|
optional int32 dummy = 0x1;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify;
|
||||||
|
option java_package = "com.spotify";
|
||||||
|
|
||||||
|
message MercuryMultiGetRequest {
|
||||||
|
repeated MercuryRequest request = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MercuryMultiGetReply {
|
||||||
|
repeated MercuryReply reply = 0x1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MercuryRequest {
|
||||||
|
optional string uri = 0x1;
|
||||||
|
optional string content_type = 0x2;
|
||||||
|
optional bytes body = 0x3;
|
||||||
|
optional bytes etag = 0x4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MercuryReply {
|
||||||
|
optional sint32 status_code = 0x1;
|
||||||
|
optional string status_message = 0x2;
|
||||||
|
optional CachePolicy cache_policy = 0x3;
|
||||||
|
enum CachePolicy {
|
||||||
|
CACHE_NO = 0x1;
|
||||||
|
CACHE_PRIVATE = 0x2;
|
||||||
|
CACHE_PUBLIC = 0x3;
|
||||||
|
}
|
||||||
|
optional sint32 ttl = 0x4;
|
||||||
|
optional bytes etag = 0x5;
|
||||||
|
optional string content_type = 0x6;
|
||||||
|
optional bytes body = 0x7;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message Header {
|
||||||
|
optional string uri = 0x01;
|
||||||
|
optional string content_type = 0x02;
|
||||||
|
optional string method = 0x03;
|
||||||
|
optional sint32 status_code = 0x04;
|
||||||
|
repeated UserField user_fields = 0x06;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UserField {
|
||||||
|
optional string key = 0x01;
|
||||||
|
optional bytes value = 0x02;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,279 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.metadata.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_outer_classname = "Metadata";
|
||||||
|
option java_package = "com.spotify.metadata";
|
||||||
|
|
||||||
|
message Artist {
|
||||||
|
optional bytes gid = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional sint32 popularity = 3;
|
||||||
|
repeated TopTracks top_track = 4;
|
||||||
|
repeated AlbumGroup album_group = 5;
|
||||||
|
repeated AlbumGroup single_group = 6;
|
||||||
|
repeated AlbumGroup compilation_group = 7;
|
||||||
|
repeated AlbumGroup appears_on_group = 8;
|
||||||
|
repeated string genre = 9;
|
||||||
|
repeated ExternalId external_id = 10;
|
||||||
|
repeated Image portrait = 11;
|
||||||
|
repeated Biography biography = 12;
|
||||||
|
repeated ActivityPeriod activity_period = 13;
|
||||||
|
repeated Restriction restriction = 14;
|
||||||
|
repeated Artist related = 15;
|
||||||
|
optional bool is_portrait_album_cover = 16;
|
||||||
|
optional ImageGroup portrait_group = 17;
|
||||||
|
repeated SalePeriod sale_period = 18;
|
||||||
|
repeated Availability availability = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Album {
|
||||||
|
optional bytes gid = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
repeated Artist artist = 3;
|
||||||
|
|
||||||
|
optional Type type = 4;
|
||||||
|
enum Type {
|
||||||
|
ALBUM = 1;
|
||||||
|
SINGLE = 2;
|
||||||
|
COMPILATION = 3;
|
||||||
|
EP = 4;
|
||||||
|
AUDIOBOOK = 5;
|
||||||
|
PODCAST = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional string label = 5;
|
||||||
|
optional Date date = 6;
|
||||||
|
optional sint32 popularity = 7;
|
||||||
|
repeated string genre = 8;
|
||||||
|
repeated Image cover = 9;
|
||||||
|
repeated ExternalId external_id = 10;
|
||||||
|
repeated Disc disc = 11;
|
||||||
|
repeated string review = 12;
|
||||||
|
repeated Copyright copyright = 13;
|
||||||
|
repeated Restriction restriction = 14;
|
||||||
|
repeated Album related = 15;
|
||||||
|
repeated SalePeriod sale_period = 16;
|
||||||
|
optional ImageGroup cover_group = 17;
|
||||||
|
optional string original_title = 18;
|
||||||
|
optional string version_title = 19;
|
||||||
|
optional string type_str = 20;
|
||||||
|
repeated Availability availability = 23;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Track {
|
||||||
|
optional bytes gid = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional Album album = 3;
|
||||||
|
repeated Artist artist = 4;
|
||||||
|
optional sint32 number = 5;
|
||||||
|
optional sint32 disc_number = 6;
|
||||||
|
optional sint32 duration = 7;
|
||||||
|
optional sint32 popularity = 8;
|
||||||
|
optional bool explicit = 9;
|
||||||
|
repeated ExternalId external_id = 10;
|
||||||
|
repeated Restriction restriction = 11;
|
||||||
|
repeated AudioFile file = 12;
|
||||||
|
repeated Track alternative = 13;
|
||||||
|
repeated SalePeriod sale_period = 14;
|
||||||
|
repeated AudioFile preview = 15;
|
||||||
|
repeated string tags = 16;
|
||||||
|
optional int64 earliest_live_timestamp = 17;
|
||||||
|
optional bool has_lyrics = 18;
|
||||||
|
repeated Availability availability = 19;
|
||||||
|
optional Licensor licensor = 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Show {
|
||||||
|
optional bytes gid = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional string description = 64;
|
||||||
|
optional sint32 deprecated_popularity = 65 [deprecated = true];
|
||||||
|
optional string publisher = 66;
|
||||||
|
optional string language = 67;
|
||||||
|
optional bool explicit = 68;
|
||||||
|
optional ImageGroup cover_image = 69;
|
||||||
|
repeated Episode episode = 70;
|
||||||
|
repeated Copyright copyright = 71;
|
||||||
|
repeated Restriction restriction = 72;
|
||||||
|
repeated string keyword = 73;
|
||||||
|
|
||||||
|
optional MediaType media_type = 74;
|
||||||
|
enum MediaType {
|
||||||
|
MIXED = 0;
|
||||||
|
AUDIO = 1;
|
||||||
|
VIDEO = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional ConsumptionOrder consumption_order = 75;
|
||||||
|
enum ConsumptionOrder {
|
||||||
|
SEQUENTIAL = 1;
|
||||||
|
EPISODIC = 2;
|
||||||
|
RECENT = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
repeated Availability availability = 78;
|
||||||
|
optional string trailer_uri = 83;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Episode {
|
||||||
|
optional bytes gid = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional sint32 duration = 7;
|
||||||
|
repeated AudioFile audio = 12;
|
||||||
|
optional string description = 64;
|
||||||
|
optional sint32 number = 65;
|
||||||
|
optional Date publish_time = 66;
|
||||||
|
optional sint32 deprecated_popularity = 67 [deprecated = true];
|
||||||
|
optional ImageGroup cover_image = 68;
|
||||||
|
optional string language = 69;
|
||||||
|
optional bool explicit = 70;
|
||||||
|
optional Show show = 71;
|
||||||
|
repeated VideoFile video = 72;
|
||||||
|
repeated VideoFile video_preview = 73;
|
||||||
|
repeated AudioFile audio_preview = 74;
|
||||||
|
repeated Restriction restriction = 75;
|
||||||
|
optional ImageGroup freeze_frame = 76;
|
||||||
|
repeated string keyword = 77;
|
||||||
|
optional bool allow_background_playback = 81;
|
||||||
|
repeated Availability availability = 82;
|
||||||
|
optional string external_url = 83;
|
||||||
|
|
||||||
|
optional .spotify.metadata.proto.Episode.EpisodeType type = 87;
|
||||||
|
enum EpisodeType {
|
||||||
|
FULL = 0;
|
||||||
|
TRAILER = 1;
|
||||||
|
BONUS = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Licensor {
|
||||||
|
optional bytes uuid = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TopTracks {
|
||||||
|
optional string country = 1;
|
||||||
|
repeated Track track = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ActivityPeriod {
|
||||||
|
optional sint32 start_year = 1;
|
||||||
|
optional sint32 end_year = 2;
|
||||||
|
optional sint32 decade = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AlbumGroup {
|
||||||
|
repeated Album album = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Date {
|
||||||
|
optional sint32 year = 1;
|
||||||
|
optional sint32 month = 2;
|
||||||
|
optional sint32 day = 3;
|
||||||
|
optional sint32 hour = 4;
|
||||||
|
optional sint32 minute = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Image {
|
||||||
|
optional bytes file_id = 1;
|
||||||
|
|
||||||
|
optional Size size = 2;
|
||||||
|
enum Size {
|
||||||
|
DEFAULT = 0;
|
||||||
|
SMALL = 1;
|
||||||
|
LARGE = 2;
|
||||||
|
XLARGE = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional sint32 width = 3;
|
||||||
|
optional sint32 height = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ImageGroup {
|
||||||
|
repeated Image image = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Biography {
|
||||||
|
optional string text = 1;
|
||||||
|
repeated Image portrait = 2;
|
||||||
|
repeated ImageGroup portrait_group = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Disc {
|
||||||
|
optional sint32 number = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
repeated Track track = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Copyright {
|
||||||
|
optional Type type = 1;
|
||||||
|
enum Type {
|
||||||
|
P = 0;
|
||||||
|
C = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional string text = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Restriction {
|
||||||
|
repeated Catalogue catalogue = 1;
|
||||||
|
enum Catalogue {
|
||||||
|
AD = 0;
|
||||||
|
SUBSCRIPTION = 1;
|
||||||
|
CATALOGUE_ALL = 2;
|
||||||
|
SHUFFLE = 3;
|
||||||
|
COMMERCIAL = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional Type type = 4;
|
||||||
|
enum Type {
|
||||||
|
STREAMING = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
repeated string catalogue_str = 5;
|
||||||
|
|
||||||
|
oneof country_restriction {
|
||||||
|
string countries_allowed = 2;
|
||||||
|
string countries_forbidden = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Availability {
|
||||||
|
repeated string catalogue_str = 1;
|
||||||
|
optional Date start = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SalePeriod {
|
||||||
|
repeated Restriction restriction = 1;
|
||||||
|
optional Date start = 2;
|
||||||
|
optional Date end = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExternalId {
|
||||||
|
optional string type = 1;
|
||||||
|
optional string id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AudioFile {
|
||||||
|
optional bytes file_id = 1;
|
||||||
|
|
||||||
|
optional Format format = 2;
|
||||||
|
enum Format {
|
||||||
|
OGG_VORBIS_96 = 0;
|
||||||
|
OGG_VORBIS_160 = 1;
|
||||||
|
OGG_VORBIS_320 = 2;
|
||||||
|
MP3_256 = 3;
|
||||||
|
MP3_320 = 4;
|
||||||
|
MP3_160 = 5;
|
||||||
|
MP3_96 = 6;
|
||||||
|
MP3_160_ENC = 7;
|
||||||
|
AAC_24 = 8;
|
||||||
|
AAC_48 = 9;
|
||||||
|
AAC_24_NORM = 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message VideoFile {
|
||||||
|
optional bytes file_id = 1;
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.context";
|
||||||
|
|
||||||
|
message PlayOrigin {
|
||||||
|
optional string feature_identifier = 1;
|
||||||
|
optional string feature_version = 2;
|
||||||
|
optional string view_uri = 3;
|
||||||
|
optional string external_referrer = 4;
|
||||||
|
optional string referrer_identifier = 5;
|
||||||
|
optional string device_identifier = 6;
|
||||||
|
repeated string feature_classes = 7;
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto.transfer;
|
||||||
|
|
||||||
|
import "context_track.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.transfer";
|
||||||
|
|
||||||
|
message Playback {
|
||||||
|
optional int64 timestamp = 1;
|
||||||
|
optional int32 position_as_of_timestamp = 2;
|
||||||
|
optional double playback_speed = 3;
|
||||||
|
optional bool is_paused = 4;
|
||||||
|
optional ContextTrack current_track = 5;
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package connectstate;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.connectstate";
|
||||||
|
|
||||||
|
message PlayerState {
|
||||||
|
int64 timestamp = 1;
|
||||||
|
string context_uri = 2;
|
||||||
|
string context_url = 3;
|
||||||
|
Restrictions context_restrictions = 4;
|
||||||
|
PlayOrigin play_origin = 5;
|
||||||
|
ContextIndex index = 6;
|
||||||
|
ProvidedTrack track = 7;
|
||||||
|
string playback_id = 8;
|
||||||
|
double playback_speed = 9;
|
||||||
|
int64 position_as_of_timestamp = 10;
|
||||||
|
int64 duration = 11;
|
||||||
|
bool is_playing = 12;
|
||||||
|
bool is_paused = 13;
|
||||||
|
bool is_buffering = 14;
|
||||||
|
bool is_system_initiated = 15;
|
||||||
|
ContextPlayerOptions options = 16;
|
||||||
|
Restrictions restrictions = 17;
|
||||||
|
Suppressions suppressions = 18;
|
||||||
|
repeated ProvidedTrack prev_tracks = 19;
|
||||||
|
repeated ProvidedTrack next_tracks = 20;
|
||||||
|
map<string, string> context_metadata = 21;
|
||||||
|
map<string, string> page_metadata = 22;
|
||||||
|
string session_id = 23;
|
||||||
|
string queue_revision = 24;
|
||||||
|
int64 position = 25;
|
||||||
|
string entity_uri = 26;
|
||||||
|
repeated ProvidedTrack reverse = 27;
|
||||||
|
repeated ProvidedTrack future = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProvidedTrack {
|
||||||
|
string uri = 1;
|
||||||
|
string uid = 2;
|
||||||
|
map<string, string> metadata = 3;
|
||||||
|
repeated string removed = 4;
|
||||||
|
repeated string blocked = 5;
|
||||||
|
string provider = 6;
|
||||||
|
Restrictions restrictions = 7;
|
||||||
|
string album_uri = 8;
|
||||||
|
repeated string disallow_reasons = 9;
|
||||||
|
string artist_uri = 10;
|
||||||
|
repeated string disallow_undecided = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContextIndex {
|
||||||
|
uint32 page = 1;
|
||||||
|
uint32 track = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Restrictions {
|
||||||
|
repeated string disallow_pausing_reasons = 1;
|
||||||
|
repeated string disallow_resuming_reasons = 2;
|
||||||
|
repeated string disallow_seeking_reasons = 3;
|
||||||
|
repeated string disallow_peeking_prev_reasons = 4;
|
||||||
|
repeated string disallow_peeking_next_reasons = 5;
|
||||||
|
repeated string disallow_skipping_prev_reasons = 6;
|
||||||
|
repeated string disallow_skipping_next_reasons = 7;
|
||||||
|
repeated string disallow_toggling_repeat_context_reasons = 8;
|
||||||
|
repeated string disallow_toggling_repeat_track_reasons = 9;
|
||||||
|
repeated string disallow_toggling_shuffle_reasons = 10;
|
||||||
|
repeated string disallow_set_queue_reasons = 11;
|
||||||
|
repeated string disallow_interrupting_playback_reasons = 12;
|
||||||
|
repeated string disallow_transferring_playback_reasons = 13;
|
||||||
|
repeated string disallow_remote_control_reasons = 14;
|
||||||
|
repeated string disallow_inserting_into_next_tracks_reasons = 15;
|
||||||
|
repeated string disallow_inserting_into_context_tracks_reasons = 16;
|
||||||
|
repeated string disallow_reordering_in_next_tracks_reasons = 17;
|
||||||
|
repeated string disallow_reordering_in_context_tracks_reasons = 18;
|
||||||
|
repeated string disallow_removing_from_next_tracks_reasons = 19;
|
||||||
|
repeated string disallow_removing_from_context_tracks_reasons = 20;
|
||||||
|
repeated string disallow_updating_context_reasons = 21;
|
||||||
|
repeated string disallow_playing_reasons = 22;
|
||||||
|
repeated string disallow_stopping_reasons = 23;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PlayOrigin {
|
||||||
|
string feature_identifier = 1;
|
||||||
|
string feature_version = 2;
|
||||||
|
string view_uri = 3;
|
||||||
|
string external_referrer = 4;
|
||||||
|
string referrer_identifier = 5;
|
||||||
|
string device_identifier = 6;
|
||||||
|
repeated string feature_classes = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ContextPlayerOptions {
|
||||||
|
bool shuffling_context = 1;
|
||||||
|
bool repeating_context = 2;
|
||||||
|
bool repeating_track = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Suppressions {
|
||||||
|
repeated string providers = 1;
|
||||||
|
}
|
|
@ -0,0 +1,229 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.playlist4.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_outer_classname = "Playlist4ApiProto";
|
||||||
|
option java_package = "com.spotify.playlist4";
|
||||||
|
|
||||||
|
message Item {
|
||||||
|
required string uri = 1;
|
||||||
|
optional ItemAttributes attributes = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MetaItem {
|
||||||
|
optional bytes revision = 1;
|
||||||
|
optional ListAttributes attributes = 2;
|
||||||
|
optional int32 length = 3;
|
||||||
|
optional int64 timestamp = 4;
|
||||||
|
optional string owner_username = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListItems {
|
||||||
|
required int32 pos = 1;
|
||||||
|
required bool truncated = 2;
|
||||||
|
repeated Item items = 3;
|
||||||
|
repeated MetaItem meta_items = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FormatListAttribute {
|
||||||
|
optional string key = 1;
|
||||||
|
optional string value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListAttributes {
|
||||||
|
optional string name = 1;
|
||||||
|
optional string description = 2;
|
||||||
|
optional bytes picture = 3;
|
||||||
|
optional bool collaborative = 4;
|
||||||
|
optional string pl3_version = 5;
|
||||||
|
optional bool deleted_by_owner = 6;
|
||||||
|
optional string client_id = 10;
|
||||||
|
optional string format = 11;
|
||||||
|
repeated FormatListAttribute format_attributes = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ItemAttributes {
|
||||||
|
optional string added_by = 1;
|
||||||
|
optional int64 timestamp = 2;
|
||||||
|
optional int64 seen_at = 9;
|
||||||
|
optional bool public = 10;
|
||||||
|
repeated FormatListAttribute format_attributes = 11;
|
||||||
|
optional bytes item_id = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Add {
|
||||||
|
optional int32 from_index = 1;
|
||||||
|
repeated Item items = 2;
|
||||||
|
optional bool add_last = 4;
|
||||||
|
optional bool add_first = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Rem {
|
||||||
|
optional int32 from_index = 1;
|
||||||
|
optional int32 length = 2;
|
||||||
|
repeated Item items = 3;
|
||||||
|
optional bool items_as_key = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Mov {
|
||||||
|
required int32 from_index = 1;
|
||||||
|
required int32 length = 2;
|
||||||
|
required int32 to_index = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ItemAttributesPartialState {
|
||||||
|
required ItemAttributes values = 1;
|
||||||
|
repeated ItemAttributeKind no_value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListAttributesPartialState {
|
||||||
|
required ListAttributes values = 1;
|
||||||
|
repeated ListAttributeKind no_value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateItemAttributes {
|
||||||
|
required int32 index = 1;
|
||||||
|
required ItemAttributesPartialState new_attributes = 2;
|
||||||
|
optional ItemAttributesPartialState old_attributes = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateListAttributes {
|
||||||
|
required ListAttributesPartialState new_attributes = 1;
|
||||||
|
optional ListAttributesPartialState old_attributes = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Op {
|
||||||
|
required Kind kind = 1;
|
||||||
|
enum Kind {
|
||||||
|
KIND_UNKNOWN = 0;
|
||||||
|
ADD = 2;
|
||||||
|
REM = 3;
|
||||||
|
MOV = 4;
|
||||||
|
UPDATE_ITEM_ATTRIBUTES = 5;
|
||||||
|
UPDATE_LIST_ATTRIBUTES = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional Add add = 2;
|
||||||
|
optional Rem rem = 3;
|
||||||
|
optional Mov mov = 4;
|
||||||
|
optional UpdateItemAttributes update_item_attributes = 5;
|
||||||
|
optional UpdateListAttributes update_list_attributes = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OpList {
|
||||||
|
repeated Op ops = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChangeInfo {
|
||||||
|
optional string user = 1;
|
||||||
|
optional int64 timestamp = 2;
|
||||||
|
optional bool admin = 3;
|
||||||
|
optional bool undo = 4;
|
||||||
|
optional bool redo = 5;
|
||||||
|
optional bool merge = 6;
|
||||||
|
optional bool compressed = 7;
|
||||||
|
optional bool migration = 8;
|
||||||
|
optional int32 split_id = 9;
|
||||||
|
optional SourceInfo source = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SourceInfo {
|
||||||
|
optional Client client = 1;
|
||||||
|
enum Client {
|
||||||
|
CLIENT_UNKNOWN = 0;
|
||||||
|
NATIVE_HERMES = 1;
|
||||||
|
CLIENT = 2;
|
||||||
|
PYTHON = 3;
|
||||||
|
JAVA = 4;
|
||||||
|
WEBPLAYER = 5;
|
||||||
|
LIBSPOTIFY = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional string app = 3;
|
||||||
|
optional string source = 4;
|
||||||
|
optional string version = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Delta {
|
||||||
|
optional bytes base_version = 1;
|
||||||
|
repeated Op ops = 2;
|
||||||
|
optional ChangeInfo info = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Diff {
|
||||||
|
required bytes from_revision = 1;
|
||||||
|
repeated Op ops = 2;
|
||||||
|
required bytes to_revision = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListChanges {
|
||||||
|
optional bytes base_revision = 1;
|
||||||
|
repeated Delta deltas = 2;
|
||||||
|
optional bool want_resulting_revisions = 3;
|
||||||
|
optional bool want_sync_result = 4;
|
||||||
|
repeated int64 nonces = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SelectedListContent {
|
||||||
|
optional bytes revision = 1;
|
||||||
|
optional int32 length = 2;
|
||||||
|
optional ListAttributes attributes = 3;
|
||||||
|
optional ListItems contents = 5;
|
||||||
|
optional Diff diff = 6;
|
||||||
|
optional Diff sync_result = 7;
|
||||||
|
repeated bytes resulting_revisions = 8;
|
||||||
|
optional bool multiple_heads = 9;
|
||||||
|
optional bool up_to_date = 10;
|
||||||
|
repeated int64 nonces = 14;
|
||||||
|
optional int64 timestamp = 15;
|
||||||
|
optional string owner_username = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateListReply {
|
||||||
|
required bytes uri = 1;
|
||||||
|
optional bytes revision = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ModifyReply {
|
||||||
|
required bytes uri = 1;
|
||||||
|
optional bytes revision = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SubscribeRequest {
|
||||||
|
repeated bytes uris = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UnsubscribeRequest {
|
||||||
|
repeated bytes uris = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PlaylistModificationInfo {
|
||||||
|
optional bytes uri = 1;
|
||||||
|
optional bytes new_revision = 2;
|
||||||
|
optional bytes parent_revision = 3;
|
||||||
|
repeated Op ops = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ListAttributeKind {
|
||||||
|
LIST_UNKNOWN = 0;
|
||||||
|
LIST_NAME = 1;
|
||||||
|
LIST_DESCRIPTION = 2;
|
||||||
|
LIST_PICTURE = 3;
|
||||||
|
LIST_COLLABORATIVE = 4;
|
||||||
|
LIST_PL3_VERSION = 5;
|
||||||
|
LIST_DELETED_BY_OWNER = 6;
|
||||||
|
LIST_CLIENT_ID = 10;
|
||||||
|
LIST_FORMAT = 11;
|
||||||
|
LIST_FORMAT_ATTRIBUTES = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ItemAttributeKind {
|
||||||
|
ITEM_UNKNOWN = 0;
|
||||||
|
ITEM_ADDED_BY = 1;
|
||||||
|
ITEM_TIMESTAMP = 2;
|
||||||
|
ITEM_SEEN_AT = 9;
|
||||||
|
ITEM_PUBLIC = 10;
|
||||||
|
ITEM_FORMAT_ATTRIBUTES = 11;
|
||||||
|
ITEM_ID = 12;
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify_playlist_annotate3.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.playlist_annotate3";
|
||||||
|
|
||||||
|
message TakedownRequest {
|
||||||
|
optional AbuseReportState abuse_report_state = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AnnotateRequest {
|
||||||
|
optional string description = 1;
|
||||||
|
optional string image_uri = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TranscodedPicture {
|
||||||
|
optional string target_name = 1;
|
||||||
|
optional string uri = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PlaylistAnnotation {
|
||||||
|
optional string description = 1;
|
||||||
|
optional string picture = 2;
|
||||||
|
optional RenderFeatures deprecated_render_features = 3 [default = NORMAL_FEATURES, deprecated = true];
|
||||||
|
repeated TranscodedPicture transcoded_picture = 4;
|
||||||
|
optional bool is_abuse_reporting_enabled = 6 [default = true];
|
||||||
|
optional AbuseReportState abuse_report_state = 7 [default = OK];
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RenderFeatures {
|
||||||
|
NORMAL_FEATURES = 1;
|
||||||
|
EXTENDED_FEATURES = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AbuseReportState {
|
||||||
|
OK = 0;
|
||||||
|
TAKEN_DOWN = 1;
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify;
|
||||||
|
option java_package = "com.spotify";
|
||||||
|
|
||||||
|
message Subscription {
|
||||||
|
optional string uri = 0x1;
|
||||||
|
optional int32 expiry = 0x2;
|
||||||
|
optional int32 status_code = 0x3;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto.transfer;
|
||||||
|
|
||||||
|
import "context_track.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.transfer";
|
||||||
|
|
||||||
|
message Queue {
|
||||||
|
repeated ContextTrack tracks = 1;
|
||||||
|
optional bool is_playing_queue = 2;
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.context";
|
||||||
|
|
||||||
|
message Restrictions {
|
||||||
|
repeated string disallow_pausing_reasons = 1;
|
||||||
|
repeated string disallow_resuming_reasons = 2;
|
||||||
|
repeated string disallow_seeking_reasons = 3;
|
||||||
|
repeated string disallow_peeking_prev_reasons = 4;
|
||||||
|
repeated string disallow_peeking_next_reasons = 5;
|
||||||
|
repeated string disallow_skipping_prev_reasons = 6;
|
||||||
|
repeated string disallow_skipping_next_reasons = 7;
|
||||||
|
repeated string disallow_toggling_repeat_context_reasons = 8;
|
||||||
|
repeated string disallow_toggling_repeat_track_reasons = 9;
|
||||||
|
repeated string disallow_toggling_shuffle_reasons = 10;
|
||||||
|
repeated string disallow_set_queue_reasons = 11;
|
||||||
|
repeated string disallow_interrupting_playback_reasons = 12;
|
||||||
|
repeated string disallow_transferring_playback_reasons = 13;
|
||||||
|
repeated string disallow_remote_control_reasons = 14;
|
||||||
|
repeated string disallow_inserting_into_next_tracks_reasons = 15;
|
||||||
|
repeated string disallow_inserting_into_context_tracks_reasons = 16;
|
||||||
|
repeated string disallow_reordering_in_next_tracks_reasons = 17;
|
||||||
|
repeated string disallow_reordering_in_context_tracks_reasons = 18;
|
||||||
|
repeated string disallow_removing_from_next_tracks_reasons = 19;
|
||||||
|
repeated string disallow_removing_from_context_tracks_reasons = 20;
|
||||||
|
repeated string disallow_updating_context_reasons = 21;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto.transfer;
|
||||||
|
|
||||||
|
import "context.proto";
|
||||||
|
import "context_player_options.proto";
|
||||||
|
import "play_origin.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.transfer";
|
||||||
|
|
||||||
|
message Session {
|
||||||
|
optional PlayOrigin play_origin = 1;
|
||||||
|
optional Context context = 2;
|
||||||
|
optional string current_uid = 3;
|
||||||
|
optional ContextPlayerOptionOverrides option_overrides = 4;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3.challenges;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
message CodeChallenge {
|
||||||
|
enum Method {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
SMS = 1;
|
||||||
|
}
|
||||||
|
.spotify.login5.v3.challenges.CodeChallenge.Method method = 1;
|
||||||
|
int32 code_length = 2;
|
||||||
|
int32 expires_in = 3;
|
||||||
|
string canonical_phone_number = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CodeSolution {
|
||||||
|
string code = 1;
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3.challenges;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
import "google/protobuf/duration.proto";
|
||||||
|
|
||||||
|
message HashcashChallenge {
|
||||||
|
bytes prefix = 1;
|
||||||
|
int32 length = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message HashcashSolution {
|
||||||
|
bytes suffix = 1;
|
||||||
|
.google.protobuf.Duration duration = 2;
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
message ClientInfo {
|
||||||
|
string client_id = 1;
|
||||||
|
string device_id = 2;
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3.credentials;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
message StoredCredential {
|
||||||
|
string username = 1;
|
||||||
|
bytes data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Password {
|
||||||
|
string id = 1;
|
||||||
|
string password = 2;
|
||||||
|
bytes padding = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FacebookAccessToken {
|
||||||
|
string fb_uid = 1;
|
||||||
|
string access_token = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OneTimeToken {
|
||||||
|
string token = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ParentChildCredential {
|
||||||
|
string child_id = 1;
|
||||||
|
.spotify.login5.v3.credentials.StoredCredential parent_stored_credential = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AppleSignInCredential {
|
||||||
|
string auth_code = 1;
|
||||||
|
string redirect_uri = 2;
|
||||||
|
string bundle_id = 3;
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3.identifiers;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
message PhoneNumber {
|
||||||
|
string number = 1;
|
||||||
|
string iso_country_code = 2;
|
||||||
|
string country_calling_code = 3;
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
import "spotify/login5/v3/client_info.proto";
|
||||||
|
import "spotify/login5/v3/user_info.proto";
|
||||||
|
import "spotify/login5/v3/challenges/code.proto";
|
||||||
|
import "spotify/login5/v3/challenges/hashcash.proto";
|
||||||
|
import "spotify/login5/v3/credentials/credentials.proto";
|
||||||
|
import "spotify/login5/v3/identifiers/identifiers.proto";
|
||||||
|
|
||||||
|
enum LoginError {
|
||||||
|
UNKNOWN_ERROR = 0;
|
||||||
|
INVALID_CREDENTIALS = 1;
|
||||||
|
BAD_REQUEST = 2;
|
||||||
|
UNSUPPORTED_LOGIN_PROTOCOL = 3;
|
||||||
|
TIMEOUT = 4;
|
||||||
|
UNKNOWN_IDENTIFIER = 5;
|
||||||
|
TOO_MANY_ATTEMPTS = 6;
|
||||||
|
INVALID_PHONENUMBER = 7;
|
||||||
|
TRY_AGAIN_LATER = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Challenges {
|
||||||
|
repeated .spotify.login5.v3.Challenge challenges = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Challenge {
|
||||||
|
.spotify.login5.v3.challenges.HashcashChallenge hashcash = 1;
|
||||||
|
.spotify.login5.v3.challenges.CodeChallenge code = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChallengeSolutions {
|
||||||
|
repeated .spotify.login5.v3.ChallengeSolution solutions = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChallengeSolution {
|
||||||
|
.spotify.login5.v3.challenges.HashcashSolution hashcash = 1;
|
||||||
|
.spotify.login5.v3.challenges.CodeSolution code = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginRequest {
|
||||||
|
.spotify.login5.v3.ClientInfo client_info = 1;
|
||||||
|
bytes login_context = 2;
|
||||||
|
.spotify.login5.v3.ChallengeSolutions challenge_solutions = 3;
|
||||||
|
.spotify.login5.v3.credentials.StoredCredential stored_credential = 100;
|
||||||
|
.spotify.login5.v3.credentials.Password password = 101;
|
||||||
|
.spotify.login5.v3.credentials.FacebookAccessToken facebook_access_token = 102;
|
||||||
|
.spotify.login5.v3.identifiers.PhoneNumber phone_number = 103;
|
||||||
|
.spotify.login5.v3.credentials.OneTimeToken one_time_token = 104;
|
||||||
|
.spotify.login5.v3.credentials.ParentChildCredential parent_child_credential = 105;
|
||||||
|
.spotify.login5.v3.credentials.AppleSignInCredential apple_sign_in_credential = 106;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginOk {
|
||||||
|
string username = 1;
|
||||||
|
string access_token = 2;
|
||||||
|
bytes stored_credential = 3;
|
||||||
|
int32 access_token_expires_in = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LoginResponse {
|
||||||
|
enum Warnings {
|
||||||
|
UNKNOWN_WARNING = 0;
|
||||||
|
DEPRECATED_PROTOCOL_VERSION = 1;
|
||||||
|
}
|
||||||
|
.spotify.login5.v3.LoginOk ok = 1;
|
||||||
|
.spotify.login5.v3.LoginError error = 2;
|
||||||
|
.spotify.login5.v3.Challenges challenges = 3;
|
||||||
|
repeated .spotify.login5.v3.LoginResponse.Warnings warnings = 4;
|
||||||
|
bytes login_context = 5;
|
||||||
|
string identifier_token = 6;
|
||||||
|
.spotify.login5.v3.UserInfo user_info = 7;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.login5.v3;
|
||||||
|
option java_package = "com.spotify.login5v3";
|
||||||
|
|
||||||
|
message UserInfo {
|
||||||
|
enum Gender {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
MALE = 1;
|
||||||
|
FEMALE = 2;
|
||||||
|
NEUTRAL = 3;
|
||||||
|
}
|
||||||
|
string name = 1;
|
||||||
|
string email = 2;
|
||||||
|
bool email_verified = 3;
|
||||||
|
string birthdate = 4;
|
||||||
|
.spotify.login5.v3.UserInfo.Gender gender = 5;
|
||||||
|
string phone_number = 6;
|
||||||
|
bool phone_number_verified = 7;
|
||||||
|
bool email_already_registered = 8;
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package spotify.download.proto;
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.storage";
|
||||||
|
|
||||||
|
message StorageResolveResponse {
|
||||||
|
Result result = 1;
|
||||||
|
enum Result {
|
||||||
|
CDN = 0;
|
||||||
|
STORAGE = 1;
|
||||||
|
RESTRICTED = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
repeated string cdnurl = 2;
|
||||||
|
bytes fileid = 4;
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package spotify.player.proto.transfer;
|
||||||
|
|
||||||
|
import "context_player_options.proto";
|
||||||
|
import "playback.proto";
|
||||||
|
import "session.proto";
|
||||||
|
import "queue.proto";
|
||||||
|
|
||||||
|
option optimize_for = CODE_SIZE;
|
||||||
|
option java_package = "com.spotify.transfer";
|
||||||
|
|
||||||
|
message TransferState {
|
||||||
|
optional ContextPlayerOptions options = 1;
|
||||||
|
optional Playback playback = 2;
|
||||||
|
optional Session current_session = 3;
|
||||||
|
optional Queue queue = 4;
|
||||||
|
optional int64 creation_timestamp = 5;
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
defusedxml
|
||||||
|
protobuf
|
||||||
|
pycryptodome
|
||||||
|
requests
|
|
@ -0,0 +1,19 @@
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
setuptools.setup(
|
||||||
|
name="librespot",
|
||||||
|
version="0.0.1-SNAPSHOT",
|
||||||
|
description="Open Source Spotify Client",
|
||||||
|
long_description=open("README.md").read(),
|
||||||
|
long_description_content_type="text/markdown",
|
||||||
|
author="kokarare1212",
|
||||||
|
url="https://github.com/kokarare1212/librespot-python",
|
||||||
|
license="Apache-2.0",
|
||||||
|
packages=setuptools.find_packages("src"),
|
||||||
|
package_dir={"": "src"},
|
||||||
|
install_requires=["defusedxml", "protobuf", "pycryptodome", "requests"],
|
||||||
|
classifiers=[
|
||||||
|
"Development Status :: 1 - Planning",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Topic :: Multimedia :: Sound/Audio"
|
||||||
|
])
|
|
@ -0,0 +1,32 @@
|
||||||
|
from librespot.proto.Keyexchange import *
|
||||||
|
import platform
|
||||||
|
|
||||||
|
|
||||||
|
class Version:
|
||||||
|
version = "0.0.1-SNAPSHOT"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def platform() -> Platform:
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
return Platform.PLATFORM_WIN32_X86
|
||||||
|
elif platform.system() == "Darwin":
|
||||||
|
return Platform.PLATFORM_OSX_X86
|
||||||
|
else:
|
||||||
|
return Platform.PLATFORM_LINUX_X86
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def version_string():
|
||||||
|
return "librespot-python " + Version.version
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def system_info_string():
|
||||||
|
return Version.version_string(
|
||||||
|
) + "; Python " + platform.python_version() + "; " + platform.system()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def standard_build_info() -> BuildInfo:
|
||||||
|
return BuildInfo(product=Product.PRODUCT_CLIENT,
|
||||||
|
product_flags=[ProductFlags.PRODUCT_FLAG_NONE],
|
||||||
|
platform=Version.platform(),
|
||||||
|
version=112800721)
|
|
@ -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))
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
class GeneralWritableStream:
|
||||||
|
def write_chunk(self, buffer: bytearray, chunk_index: int, cached: bool):
|
||||||
|
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
|
@ -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)
|
|
@ -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
|
|
@ -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))
|
|
@ -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))
|
|
@ -0,0 +1,2 @@
|
||||||
|
from librespot.audio.cdn.CdnFeedHelper import CdnFeedHelper
|
||||||
|
from librespot.audio.cdn.CdnManager import ChannelManager
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
||||||
|
class AudioDecrypt:
|
||||||
|
def decrypt_chunk(self, chunk_index: int, buffer: bytes):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def decrypt_time_ms(self):
|
||||||
|
pass
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
|
@ -0,0 +1,2 @@
|
||||||
|
from librespot.audio.format.AudioQualityPicker import AudioQualityPicker
|
||||||
|
from librespot.audio.format.SuperAudioFormat import SuperAudioFormat
|
|
@ -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
|
|
@ -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
|
|
@ -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")
|
|
@ -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
|
|
@ -0,0 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.core import Session
|
||||||
|
|
||||||
|
|
||||||
|
class CacheManager:
|
||||||
|
CLEAN_UP_THRESHOLD = 604800000
|
||||||
|
HEADER_TIMESTAMP = 254
|
||||||
|
HEADER_HASH = 253
|
||||||
|
|
||||||
|
parent: str
|
||||||
|
|
||||||
|
def __init__(self, conf: Session.Configuration):
|
||||||
|
pass
|
|
@ -0,0 +1 @@
|
||||||
|
from librespot.cache.CacheManager import CacheManager
|
|
@ -0,0 +1,88 @@
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
class Base62:
|
||||||
|
standard_base = 256
|
||||||
|
target_base = 62
|
||||||
|
alphabet: bytes
|
||||||
|
lookup: bytearray
|
||||||
|
|
||||||
|
def __init__(self, alphabet: bytes):
|
||||||
|
self.alphabet = alphabet
|
||||||
|
self.create_lookup_table()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_instance_with_inverted_character_set():
|
||||||
|
return Base62(Base62.CharacterSets.inverted)
|
||||||
|
|
||||||
|
def encode(self, message: bytes, length: int = -1):
|
||||||
|
indices = self.convert(message, self.standard_base, self.target_base,
|
||||||
|
length)
|
||||||
|
return self.translate(indices, self.alphabet)
|
||||||
|
|
||||||
|
def decode(self, encoded: bytes, length: int = -1):
|
||||||
|
prepared = self.translate(encoded, self.lookup)
|
||||||
|
return self.convert(prepared, self.target_base, self.standard_base,
|
||||||
|
length)
|
||||||
|
|
||||||
|
def translate(self, indices: bytes, dictionary: bytes):
|
||||||
|
translation = bytearray(len(indices))
|
||||||
|
for i in range(len(indices)):
|
||||||
|
translation[i] = dictionary[int.from_bytes(indices[i].encode(),
|
||||||
|
"big")]
|
||||||
|
|
||||||
|
return translation
|
||||||
|
|
||||||
|
def convert(self, message: bytes, source_base: int, target_base: int,
|
||||||
|
length: int):
|
||||||
|
estimated_length = self.estimate_output_length(
|
||||||
|
len(message), source_base, target_base) if length == -1 else length
|
||||||
|
out = b""
|
||||||
|
source = message
|
||||||
|
while len(source) > 0:
|
||||||
|
quotient = b""
|
||||||
|
remainder = 0
|
||||||
|
for b in source:
|
||||||
|
accumulator = int(b & 0xff) + remainder * source_base
|
||||||
|
digit = int(
|
||||||
|
(accumulator - (accumulator % target_base)) / target_base)
|
||||||
|
remainder = int(accumulator % target_base)
|
||||||
|
if len(quotient) > 0 or digit > 0:
|
||||||
|
quotient += bytes([digit])
|
||||||
|
|
||||||
|
out += bytes([remainder])
|
||||||
|
source = quotient
|
||||||
|
|
||||||
|
if len(out) < estimated_length:
|
||||||
|
size = len(out)
|
||||||
|
for i in range(estimated_length - size):
|
||||||
|
out += bytes([0])
|
||||||
|
|
||||||
|
return self.reverse(out)
|
||||||
|
elif len(out) > estimated_length:
|
||||||
|
return self.reverse(out[:estimated_length])
|
||||||
|
else:
|
||||||
|
return self.reverse(out)
|
||||||
|
|
||||||
|
def estimate_output_length(self, input_length: int, source_base: int,
|
||||||
|
target_base: int):
|
||||||
|
return int(
|
||||||
|
math.ceil((math.log(source_base) / math.log(target_base)) *
|
||||||
|
input_length))
|
||||||
|
|
||||||
|
def reverse(self, arr: bytes):
|
||||||
|
length = len(arr)
|
||||||
|
reversed_arr = bytearray(length)
|
||||||
|
for i in range(length):
|
||||||
|
reversed_arr[length - i - 1] = arr[i]
|
||||||
|
|
||||||
|
return bytes(reversed_arr)
|
||||||
|
|
||||||
|
def create_lookup_table(self):
|
||||||
|
self.lookup = bytearray(256)
|
||||||
|
for i in range(len(self.alphabet)):
|
||||||
|
self.lookup[self.alphabet[i]] = i & 0xff
|
||||||
|
|
||||||
|
class CharacterSets:
|
||||||
|
gmp = b'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||||
|
inverted = b'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
@ -0,0 +1,31 @@
|
||||||
|
import binascii
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Utils:
|
||||||
|
@staticmethod
|
||||||
|
def random_hex_string(length: int):
|
||||||
|
buffer = os.urandom(int(length / 2))
|
||||||
|
return Utils.bytes_to_hex(buffer)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def split(s: str, c: str):
|
||||||
|
return s.split(c)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_byte_array(i: int) -> bytes:
|
||||||
|
width = i.bit_length()
|
||||||
|
width += 8 - ((width % 8) or 8)
|
||||||
|
fmt = '%%0%dx' % (width // 4)
|
||||||
|
if i == 0:
|
||||||
|
return bytes([0])
|
||||||
|
else:
|
||||||
|
return binascii.unhexlify(fmt % i)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def bytes_to_hex(buffer: bytes) -> str:
|
||||||
|
return binascii.hexlify(buffer).decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hex_to_bytes(s: str) -> bytes:
|
||||||
|
return binascii.unhexlify(s)
|
|
@ -0,0 +1,2 @@
|
||||||
|
from librespot.common.Base62 import Base62
|
||||||
|
from librespot.common.Utils import Utils
|
|
@ -0,0 +1,33 @@
|
||||||
|
import queue
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class ApResolver:
|
||||||
|
base_url = "http://apresolve.spotify.com/"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def request(service_type: str):
|
||||||
|
response = requests.get("{}?type={}".format(ApResolver.base_url,
|
||||||
|
service_type))
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_random_of(service_type: str):
|
||||||
|
pool = ApResolver.request(service_type)
|
||||||
|
urls = pool.get(service_type)
|
||||||
|
if urls is None or len(urls) == 0:
|
||||||
|
raise RuntimeError()
|
||||||
|
return random.choice(urls)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_random_dealer() -> str:
|
||||||
|
return ApResolver.get_random_of("dealer")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_random_spclient() -> str:
|
||||||
|
return ApResolver.get_random_of("spclient")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_random_accesspoint() -> str:
|
||||||
|
return ApResolver.get_random_of("accesspoint")
|
|
@ -0,0 +1,103 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
import concurrent.futures
|
||||||
|
import enum
|
||||||
|
import time
|
||||||
|
import typing
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from librespot.core import Session
|
||||||
|
from librespot.mercury import RawMercuryRequest
|
||||||
|
from librespot.standard import ByteArrayOutputStream
|
||||||
|
|
||||||
|
|
||||||
|
class EventService:
|
||||||
|
_session: Session
|
||||||
|
_LOGGER: logging = logging.getLogger(__name__)
|
||||||
|
_worker: concurrent.futures.ThreadPoolExecutor = concurrent.futures.ThreadPoolExecutor(
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def _worker_callback(self, event_builder: EventService.EventBuilder):
|
||||||
|
try:
|
||||||
|
body = event_builder.to_array()
|
||||||
|
resp = self._session.mercury().send_sync(RawMercuryRequest.Builder(
|
||||||
|
).set_uri("hm://event-service/v1/events").set_method(
|
||||||
|
"POST").add_user_field("Accept-Language", "en").add_user_field(
|
||||||
|
"X-ClientTimeStamp",
|
||||||
|
int(time.time() * 1000)).add_payload_part(body).build())
|
||||||
|
|
||||||
|
self._LOGGER.debug("Event sent. body: {}, result: {}".format(
|
||||||
|
body, resp.status_code))
|
||||||
|
except IOError as ex:
|
||||||
|
self._LOGGER.error("Failed sending event: {} {}".format(
|
||||||
|
event_builder, ex))
|
||||||
|
|
||||||
|
def send_event(self,
|
||||||
|
event_or_builder: typing.Union[EventService.GenericEvent,
|
||||||
|
EventService.EventBuilder]):
|
||||||
|
if type(event_or_builder) is EventService.GenericEvent:
|
||||||
|
builder = event_or_builder.build()
|
||||||
|
elif type(event_or_builder) is EventService.EventBuilder:
|
||||||
|
builder = event_or_builder
|
||||||
|
else:
|
||||||
|
TypeError()
|
||||||
|
self._worker.submit(lambda: self._worker_callback(builder))
|
||||||
|
|
||||||
|
def language(self, lang: str):
|
||||||
|
event = EventService.EventBuilder(EventService.Type.LANGUAGE)
|
||||||
|
event.append(s=lang)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Type(enum.Enum):
|
||||||
|
LANGUAGE = ("812", 1)
|
||||||
|
FETCHED_FILE_ID = ("274", 3)
|
||||||
|
NEW_SESSION_ID = ("557", 3)
|
||||||
|
NEW_PLAYBACK_ID = ("558", 1)
|
||||||
|
TRACK_PLAYED = ("372", 1)
|
||||||
|
TRACK_TRANSITION = ("12", 37)
|
||||||
|
CDN_REQUEST = ("10", 20)
|
||||||
|
|
||||||
|
_eventId: str
|
||||||
|
_unknown: str
|
||||||
|
|
||||||
|
def __init__(self, event_id: str, unknown: str):
|
||||||
|
self._eventId = event_id
|
||||||
|
self._unknown = unknown
|
||||||
|
|
||||||
|
class GenericEvent:
|
||||||
|
def build(self) -> EventService.EventBuilder:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class EventBuilder:
|
||||||
|
body: ByteArrayOutputStream = ByteArrayOutputStream(256)
|
||||||
|
|
||||||
|
def __init__(self, type: EventService.Type):
|
||||||
|
self.append_no_delimiter(type.value[0])
|
||||||
|
self.append(type.value[1])
|
||||||
|
|
||||||
|
def append_no_delimiter(self, s: str = None) -> None:
|
||||||
|
if s is None:
|
||||||
|
s = ""
|
||||||
|
|
||||||
|
self.body.write(buffer=bytearray(s.encode()))
|
||||||
|
|
||||||
|
def append(self,
|
||||||
|
c: int = None,
|
||||||
|
s: str = None) -> EventService.EventBuilder:
|
||||||
|
if c is None and s is None or c is not None and s is not None:
|
||||||
|
raise TypeError()
|
||||||
|
if c is not None:
|
||||||
|
self.body.write(byte=0x09)
|
||||||
|
self.body.write(byte=c)
|
||||||
|
return self
|
||||||
|
elif s is not None:
|
||||||
|
self.body.write(byte=0x09)
|
||||||
|
self.append_no_delimiter(s)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def to_array(self) -> bytearray:
|
||||||
|
return self.body.to_byte_array()
|
|
@ -0,0 +1,6 @@
|
||||||
|
from librespot.crypto.Packet import Packet
|
||||||
|
|
||||||
|
|
||||||
|
class PacketsReceiver:
|
||||||
|
def dispatch(self, packet: Packet):
|
||||||
|
pass
|
|
@ -0,0 +1,10 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.core import Session
|
||||||
|
|
||||||
|
|
||||||
|
class SearchManager:
|
||||||
|
_BASE_URL: str = "hm://searchview/km/v4/search/"
|
||||||
|
_session: Session
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self._session = session
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,36 @@
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class TimeProvider:
|
||||||
|
offset = 0
|
||||||
|
method = 0x00
|
||||||
|
|
||||||
|
def init(self, conf=None, session=None):
|
||||||
|
if conf is None and session is None:
|
||||||
|
return
|
||||||
|
if conf is not None:
|
||||||
|
self.method = conf.time_synchronization_method
|
||||||
|
if conf.time_synchronization_method == TimeProvider.Method.ntp:
|
||||||
|
self.update_with_ntp()
|
||||||
|
if conf.time_synchronization_method == TimeProvider.Method.manual:
|
||||||
|
self.offset = conf.time_manual_correction
|
||||||
|
if session is not None:
|
||||||
|
if self.method != TimeProvider.Method.melody:
|
||||||
|
return
|
||||||
|
self.update_melody(session)
|
||||||
|
|
||||||
|
def current_time_millis(self):
|
||||||
|
return math.floor(time.time() * 1000) + self.offset
|
||||||
|
|
||||||
|
def update_melody(self, session):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_with_ntp(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Method:
|
||||||
|
ntp = 0x00
|
||||||
|
ping = 0x01
|
||||||
|
melody = 0x02
|
||||||
|
manual = 0x03
|
|
@ -0,0 +1,84 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.core import Session, TimeProvider
|
||||||
|
from librespot.mercury import MercuryRequests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class TokenProvider:
|
||||||
|
_LOGGER: logging = logging.getLogger(__name__)
|
||||||
|
_TOKEN_EXPIRE_THRESHOLD = 10
|
||||||
|
_session: Session = None
|
||||||
|
_tokens: list[TokenProvider.StoredToken] = []
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def find_token_with_all_scopes(
|
||||||
|
self, scopes: list[str]) -> TokenProvider.StoredToken:
|
||||||
|
for token in self._tokens:
|
||||||
|
if token.has_scopes(scopes):
|
||||||
|
return token
|
||||||
|
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_token(self, *scopes) -> TokenProvider.StoredToken:
|
||||||
|
scopes = list(scopes)
|
||||||
|
if len(scopes) == 0:
|
||||||
|
raise RuntimeError()
|
||||||
|
|
||||||
|
token = self.find_token_with_all_scopes(scopes)
|
||||||
|
if token is not None:
|
||||||
|
if token.expired():
|
||||||
|
self._tokens.remove(token)
|
||||||
|
else:
|
||||||
|
return token
|
||||||
|
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Token expired or not suitable, requesting again. scopes: {}, old_token: {}"
|
||||||
|
.format(scopes, token))
|
||||||
|
resp = self._session.mercury().send_sync_json(
|
||||||
|
MercuryRequests.request_token(self._session.device_id(),
|
||||||
|
",".join(scopes)))
|
||||||
|
token = TokenProvider.StoredToken(resp)
|
||||||
|
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Updated token successfully! scopes: {}, new_token: {}".format(
|
||||||
|
scopes, token))
|
||||||
|
self._tokens.append(token)
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
def get(self, scope: str) -> str:
|
||||||
|
return self.get_token(scope).access_token
|
||||||
|
|
||||||
|
class StoredToken:
|
||||||
|
expires_in: int
|
||||||
|
access_token: str
|
||||||
|
scopes: list[str]
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
def __init__(self, obj):
|
||||||
|
self.timestamp = TimeProvider.TimeProvider().current_time_millis()
|
||||||
|
self.expires_in = obj["expiresIn"]
|
||||||
|
self.access_token = obj["accessToken"]
|
||||||
|
self.scopes = obj["scope"]
|
||||||
|
|
||||||
|
def expired(self) -> bool:
|
||||||
|
return self.timestamp + (
|
||||||
|
self.expires_in - TokenProvider._TOKEN_EXPIRE_THRESHOLD
|
||||||
|
) * 1000 < TimeProvider.TimeProvider().current_time_millis()
|
||||||
|
|
||||||
|
def has_scope(self, scope: str) -> bool:
|
||||||
|
for s in self.scopes:
|
||||||
|
if s == scope:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_scopes(self, sc: list[str]) -> bool:
|
||||||
|
for s in sc:
|
||||||
|
if not self.has_scope(s):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
|
@ -0,0 +1,7 @@
|
||||||
|
from librespot.core.ApResolver import ApResolver
|
||||||
|
from librespot.core.EventService import EventService
|
||||||
|
from librespot.core.PacketsReceiver import PacketsReceiver
|
||||||
|
from librespot.core.SearchManager import SearchManager
|
||||||
|
from librespot.core.Session import Session
|
||||||
|
from librespot.core.TimeProvider import TimeProvider
|
||||||
|
from librespot.core.TokenProvider import TokenProvider
|
|
@ -0,0 +1,62 @@
|
||||||
|
from librespot.crypto.Packet import Packet
|
||||||
|
from librespot.crypto.Shannon import Shannon
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
class CipherPair:
|
||||||
|
send_cipher: Shannon
|
||||||
|
receive_cipher: Shannon
|
||||||
|
send_nonce = 0
|
||||||
|
receive_nonce = 0
|
||||||
|
|
||||||
|
def __init__(self, send_key: bytes, receive_key: bytes):
|
||||||
|
# self.send_cipher = Shannon()
|
||||||
|
# self.send_cipher.key(send_key)
|
||||||
|
self.send_cipher = Shannon(send_key)
|
||||||
|
self.send_nonce = 0
|
||||||
|
|
||||||
|
# self.receive_cipher = Shannon()
|
||||||
|
# self.receive_cipher.key(receive_key)
|
||||||
|
self.receive_cipher = Shannon(receive_key)
|
||||||
|
self.receive_nonce = 0
|
||||||
|
|
||||||
|
def send_encoded(self, conn, cmd: bytes, payload: bytes):
|
||||||
|
self.send_cipher.nonce(self.send_nonce)
|
||||||
|
self.send_nonce += 1
|
||||||
|
|
||||||
|
buffer = b""
|
||||||
|
buffer += cmd
|
||||||
|
buffer += struct.pack(">H", len(payload))
|
||||||
|
buffer += payload
|
||||||
|
|
||||||
|
buffer = self.send_cipher.encrypt(buffer)
|
||||||
|
|
||||||
|
# mac = self.send_cipher.finish(bytes(4))
|
||||||
|
mac = self.send_cipher.finish(4)
|
||||||
|
|
||||||
|
conn.write(buffer)
|
||||||
|
conn.write(mac)
|
||||||
|
conn.flush()
|
||||||
|
|
||||||
|
def receive_encoded(self, conn) -> Packet:
|
||||||
|
try:
|
||||||
|
self.receive_cipher.nonce(self.receive_nonce)
|
||||||
|
self.receive_nonce += 1
|
||||||
|
|
||||||
|
header_bytes = self.receive_cipher.decrypt(conn.read(3))
|
||||||
|
|
||||||
|
cmd = struct.pack(">s", bytes([header_bytes[0]]))
|
||||||
|
payload_length = (header_bytes[1] << 8) | (header_bytes[2] & 0xff)
|
||||||
|
|
||||||
|
payload_bytes = self.receive_cipher.decrypt(
|
||||||
|
conn.read(payload_length))
|
||||||
|
|
||||||
|
mac = conn.read(4)
|
||||||
|
|
||||||
|
expected_mac = self.receive_cipher.finish(4)
|
||||||
|
if mac != expected_mac:
|
||||||
|
raise RuntimeError()
|
||||||
|
|
||||||
|
return Packet(cmd, payload_bytes)
|
||||||
|
except IndexError:
|
||||||
|
raise RuntimeError()
|
|
@ -0,0 +1,38 @@
|
||||||
|
from binascii import unhexlify
|
||||||
|
from librespot.common.Utils import Utils
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
class DiffieHellman:
|
||||||
|
prime_bytes: bytearray = bytes([
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc9, 0x0f, 0xda, 0xa2,
|
||||||
|
0x21, 0x68, 0xc2, 0x34, 0xc4, 0xc6, 0x62, 0x8b, 0x80, 0xdc, 0x1c, 0xd1,
|
||||||
|
0x29, 0x02, 0x4e, 0x08, 0x8a, 0x67, 0xcc, 0x74, 0x02, 0x0b, 0xbe, 0xa6,
|
||||||
|
0x3b, 0x13, 0x9b, 0x22, 0x51, 0x4a, 0x08, 0x79, 0x8e, 0x34, 0x04, 0xdd,
|
||||||
|
0xef, 0x95, 0x19, 0xb3, 0xcd, 0x3a, 0x43, 0x1b, 0x30, 0x2b, 0x0a, 0x6d,
|
||||||
|
0xf2, 0x5f, 0x14, 0x37, 0x4f, 0xe1, 0x35, 0x6d, 0x6d, 0x51, 0xc2, 0x45,
|
||||||
|
0xe4, 0x85, 0xb5, 0x76, 0x62, 0x5e, 0x7e, 0xc6, 0xf4, 0x4c, 0x42, 0xe9,
|
||||||
|
0xa6, 0x3a, 0x36, 0x20, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
|
||||||
|
])
|
||||||
|
prime: int = int.from_bytes(prime_bytes, "big")
|
||||||
|
private_key: int
|
||||||
|
public_key: int
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
key_data = os.urandom(95)
|
||||||
|
self.private_key = int.from_bytes(key_data, "big")
|
||||||
|
self.public_key = pow(2, self.private_key, self.prime)
|
||||||
|
|
||||||
|
def compute_shared_key(self, remote_key_bytes: bytes):
|
||||||
|
remote_key = int.from_bytes(remote_key_bytes, "big")
|
||||||
|
return pow(remote_key, self.private_key, self.prime)
|
||||||
|
|
||||||
|
def private_key(self):
|
||||||
|
return self.private_key
|
||||||
|
|
||||||
|
def public_key(self):
|
||||||
|
return self.public_key
|
||||||
|
|
||||||
|
def public_key_array(self):
|
||||||
|
return Utils.to_byte_array(self.public_key)
|
|
@ -0,0 +1,66 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class Packet:
|
||||||
|
cmd: bytes
|
||||||
|
payload: bytes
|
||||||
|
|
||||||
|
def __init__(self, cmd: bytes, payload: bytes):
|
||||||
|
self.cmd = cmd
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def is_cmd(self, cmd: bytes):
|
||||||
|
return cmd == self.cmd
|
||||||
|
|
||||||
|
class Type:
|
||||||
|
secret_block = b"\x02"
|
||||||
|
ping = b"\x04"
|
||||||
|
stream_chunk = b"\x08"
|
||||||
|
stream_chunk_res = b"\x09"
|
||||||
|
channel_error = b"\x0a"
|
||||||
|
channel_abort = b"\x0b"
|
||||||
|
request_key = b"\x0c"
|
||||||
|
aes_key = b"\x0d"
|
||||||
|
aes_key_error = b"\x0e"
|
||||||
|
image = b"\x19"
|
||||||
|
country_code = b"\x1b"
|
||||||
|
pong = b"\x49"
|
||||||
|
pong_ack = b"\x4a"
|
||||||
|
pause = b"\x4b"
|
||||||
|
product_info = b"\x50"
|
||||||
|
legacy_welcome = b"\x69"
|
||||||
|
license_version = b"\x76"
|
||||||
|
login = b"\xab"
|
||||||
|
ap_welcome = b"\xac"
|
||||||
|
auth_failure = b"\xad"
|
||||||
|
mercury_req = b"\xb2"
|
||||||
|
mercury_sub = b"\xb3"
|
||||||
|
mercury_unsub = b"\xb4"
|
||||||
|
mercury_event = b"\xb5"
|
||||||
|
track_ended_time = b"\x82"
|
||||||
|
unknown_data_all_zeros = b"\x1f"
|
||||||
|
preferred_locale = b"\x74"
|
||||||
|
unknown_0x4f = b"\x4f"
|
||||||
|
unknown_0x0f = b"\x0f"
|
||||||
|
unknown_0x10 = b"\x10"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(val: bytes):
|
||||||
|
for cmd in [
|
||||||
|
Packet.Type.__dict__[attr]
|
||||||
|
for attr in Packet.Type.__dict__.keys()
|
||||||
|
if re.search("__.+?__", attr) is None
|
||||||
|
and type(Packet.Type.__dict__[attr]) is bytes
|
||||||
|
]:
|
||||||
|
if cmd == val:
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def for_method(method: str):
|
||||||
|
if method == "SUB":
|
||||||
|
return Packet.Type.mercury_sub
|
||||||
|
if method == "UNSUB":
|
||||||
|
return Packet.Type.mercury_unsub
|
||||||
|
return Packet.Type.mercury_req
|
|
@ -0,0 +1,495 @@
|
||||||
|
"""
|
||||||
|
Shannon: Shannon stream cipher and MAC -- reference implementation, ported from C code written by Greg Rose
|
||||||
|
https://github.com/sashahilton00/spotify-connect-resources/blob/master/Shannon-1.0/ShannonRef.c
|
||||||
|
|
||||||
|
Copyright 2017, Dmitry Borisov
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED
|
||||||
|
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE AND AGAINST
|
||||||
|
INFRINGEMENT ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct, \
|
||||||
|
copy
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
N = 16
|
||||||
|
INITKONST = 0x6996c53a
|
||||||
|
KEYP = 13 # where to insert key/MAC/counter words
|
||||||
|
FOLD = N # how many iterations of folding to do
|
||||||
|
|
||||||
|
|
||||||
|
class Shannon:
|
||||||
|
@staticmethod
|
||||||
|
def ROTL(w, x):
|
||||||
|
return ((w << x) | (w >> (32 - x))) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ROTR(w, x):
|
||||||
|
return ((w >> x) | (w << (32 - x))) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
""" Nonlinear transform (sbox) of a word.
|
||||||
|
There are two slightly different combinations. """
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sbox1(w):
|
||||||
|
w ^= Shannon.ROTL(w, 5) | Shannon.ROTL(w, 7)
|
||||||
|
w ^= Shannon.ROTL(w, 19) | Shannon.ROTL(w, 22)
|
||||||
|
return w
|
||||||
|
|
||||||
|
""" Nonlinear transform (sbox) of a word.
|
||||||
|
There are two slightly different combinations. """
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sbox2(w):
|
||||||
|
w ^= Shannon.ROTL(w, 7) | Shannon.ROTL(w, 22)
|
||||||
|
w ^= Shannon.ROTL(w, 5) | Shannon.ROTL(w, 19)
|
||||||
|
return w
|
||||||
|
|
||||||
|
""" initialise to known state """
|
||||||
|
|
||||||
|
def _initstate(self):
|
||||||
|
global N, \
|
||||||
|
INITKONST
|
||||||
|
|
||||||
|
# Generate fibonacci numbers up to N
|
||||||
|
self._R = [1, 1]
|
||||||
|
for x in range(1, N - 1):
|
||||||
|
self._R.append(self._R[x] + self._R[x - 1])
|
||||||
|
|
||||||
|
self._konst = INITKONST
|
||||||
|
|
||||||
|
""" cycle the contents of the register and calculate output word in _sbuf. """
|
||||||
|
|
||||||
|
def _cycle(self):
|
||||||
|
# nonlinear feedback function
|
||||||
|
t = self._R[12] ^ self._R[13] ^ self._konst
|
||||||
|
t = Shannon.sbox1(t) ^ Shannon.ROTL(self._R[0], 1)
|
||||||
|
|
||||||
|
# Shift to the left
|
||||||
|
self._R = self._R[1:] + [t]
|
||||||
|
t = Shannon.sbox2(self._R[2] ^ self._R[15])
|
||||||
|
self._R[0] ^= t
|
||||||
|
self._sbuf = t ^ self._R[8] ^ self._R[12]
|
||||||
|
|
||||||
|
""" The Shannon MAC function is modelled after the concepts of Phelix and SHA.
|
||||||
|
Basically, words to be accumulated in the MAC are incorporated in two
|
||||||
|
different ways:
|
||||||
|
1. They are incorporated into the stream cipher register at a place
|
||||||
|
where they will immediately have a nonlinear effect on the state
|
||||||
|
2. They are incorporated into bit-parallel CRC-16 registers; the
|
||||||
|
contents of these registers will be used in MAC finalization. """
|
||||||
|
""" Accumulate a CRC of input words, later to be fed into MAC.
|
||||||
|
This is actually 32 parallel CRC-16s, using the IBM CRC-16
|
||||||
|
polynomial x^16 + x^15 + x^2 + 1. """
|
||||||
|
|
||||||
|
def _crcfunc(self, i):
|
||||||
|
t = self._CRC[0] ^ self._CRC[2] ^ self._CRC[15] ^ i
|
||||||
|
# Accumulate CRC of input
|
||||||
|
self._CRC = self._CRC[1:] + [t]
|
||||||
|
|
||||||
|
""" Normal MAC word processing: do both stream register and CRC. """
|
||||||
|
|
||||||
|
def _macfunc(self, i):
|
||||||
|
global KEYP
|
||||||
|
|
||||||
|
self._crcfunc(i)
|
||||||
|
self._R[KEYP] ^= i
|
||||||
|
|
||||||
|
""" extra nonlinear diffusion of register for key and MAC """
|
||||||
|
|
||||||
|
def _diffuse(self):
|
||||||
|
global FOLD
|
||||||
|
|
||||||
|
for i in range(FOLD):
|
||||||
|
self._cycle()
|
||||||
|
|
||||||
|
""" Common actions for loading key material
|
||||||
|
Allow non-word-multiple key and nonce material.
|
||||||
|
Note also initializes the CRC register as a side effect. """
|
||||||
|
|
||||||
|
def _loadkey(self, key):
|
||||||
|
global KEYP, \
|
||||||
|
N
|
||||||
|
|
||||||
|
# Pad key with 00s to align on 4 bytes and add key_len
|
||||||
|
padding_size = int((len(key) + 3) / 4) * 4 - len(key)
|
||||||
|
key = key + (b'\x00' * padding_size) + struct.pack("<I", len(key))
|
||||||
|
for i in range(0, len(key), 4):
|
||||||
|
self._R[KEYP] = self._R[KEYP] ^ struct.unpack(
|
||||||
|
"<I", key[i:i + 4])[0] # Little Endian order
|
||||||
|
self._cycle()
|
||||||
|
|
||||||
|
# save a copy of the register
|
||||||
|
self._CRC = copy.copy(self._R)
|
||||||
|
|
||||||
|
# now diffuse
|
||||||
|
self._diffuse()
|
||||||
|
|
||||||
|
# now xor the copy back -- makes key loading irreversible */
|
||||||
|
for i in range(N):
|
||||||
|
self._R[i] ^= self._CRC[i]
|
||||||
|
|
||||||
|
""" Constructor """
|
||||||
|
|
||||||
|
def __init__(self, key):
|
||||||
|
self._initstate()
|
||||||
|
self._loadkey(key)
|
||||||
|
self._konst = self._R[0] # in case we proceed to stream generation
|
||||||
|
self._initR = copy.copy(self._R)
|
||||||
|
self._nbuf = 0
|
||||||
|
|
||||||
|
""" Published "IV" interface """
|
||||||
|
|
||||||
|
def nonce(self, nonce):
|
||||||
|
global INITKONST
|
||||||
|
|
||||||
|
if type(nonce) == int:
|
||||||
|
# Accept int as well (BigEndian)
|
||||||
|
nonce = bytes(struct.pack(">I", nonce))
|
||||||
|
|
||||||
|
self._R = copy.copy(self._initR)
|
||||||
|
self._konst = INITKONST
|
||||||
|
self._loadkey(nonce)
|
||||||
|
self._konst = self._R[0]
|
||||||
|
self._nbuf = 0
|
||||||
|
self._mbuf = 0
|
||||||
|
|
||||||
|
""" Encrypt small chunk """
|
||||||
|
|
||||||
|
def _encrypt_chunk(self, chunk):
|
||||||
|
result = []
|
||||||
|
for c in chunk:
|
||||||
|
self._mbuf ^= c << (32 - self._nbuf)
|
||||||
|
result.append(c ^ (self._sbuf >> (32 - self._nbuf)) & 0xFF)
|
||||||
|
self._nbuf -= 8
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
""" Combined MAC and encryption.
|
||||||
|
Note that plaintext is accumulated for MAC. """
|
||||||
|
|
||||||
|
def encrypt(self, buf):
|
||||||
|
# handle any previously buffered bytes
|
||||||
|
result = []
|
||||||
|
if self._nbuf != 0:
|
||||||
|
head = buf[:(self._nbuf >> 3)]
|
||||||
|
buf = buf[(self._nbuf >> 3):]
|
||||||
|
result = self._encrypt_chunk(head)
|
||||||
|
if self._nbuf != 0:
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
# LFSR already cycled
|
||||||
|
self._macfunc(self._mbuf)
|
||||||
|
|
||||||
|
# Handle body
|
||||||
|
i = 0
|
||||||
|
while len(buf) >= 4:
|
||||||
|
self._cycle()
|
||||||
|
t = struct.unpack("<I", buf[i:i + 4])[0]
|
||||||
|
self._macfunc(t)
|
||||||
|
t ^= self._sbuf
|
||||||
|
result += struct.pack("<I", t)
|
||||||
|
buf = buf[4:]
|
||||||
|
|
||||||
|
# handle any trailing bytes
|
||||||
|
if len(buf):
|
||||||
|
self._cycle()
|
||||||
|
self._mbuf = 0
|
||||||
|
self._nbuf = 32
|
||||||
|
result += self._encrypt_chunk(buf)
|
||||||
|
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
""" Decrypt small chunk """
|
||||||
|
|
||||||
|
def _decrypt_chunk(self, chunk):
|
||||||
|
result = []
|
||||||
|
for c in chunk:
|
||||||
|
result.append(c ^ ((self._sbuf >> (32 - self._nbuf)) & 0xFF))
|
||||||
|
self._mbuf ^= result[-1] << (32 - self._nbuf)
|
||||||
|
self._nbuf -= 8
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
""" Combined MAC and decryption.
|
||||||
|
Note that plaintext is accumulated for MAC. """
|
||||||
|
|
||||||
|
def decrypt(self, buf):
|
||||||
|
# handle any previously buffered bytes
|
||||||
|
result = []
|
||||||
|
if self._nbuf != 0:
|
||||||
|
head = buf[:(self._nbuf >> 3)]
|
||||||
|
buf = buf[(self._nbuf >> 3):]
|
||||||
|
result = self._decrypt_chunk(head)
|
||||||
|
if self._nbuf != 0:
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
# LFSR already cycled
|
||||||
|
self._macfunc(self._mbuf)
|
||||||
|
|
||||||
|
# Handle whole words
|
||||||
|
i = 0
|
||||||
|
while len(buf) >= 4:
|
||||||
|
self._cycle()
|
||||||
|
t = struct.unpack("<I", buf[i:i + 4])[0] ^ self._sbuf
|
||||||
|
self._macfunc(t)
|
||||||
|
result += struct.pack("<I", t)
|
||||||
|
buf = buf[4:]
|
||||||
|
|
||||||
|
# handle any trailing bytes
|
||||||
|
if len(buf):
|
||||||
|
self._cycle()
|
||||||
|
self._mbuf = 0
|
||||||
|
self._nbuf = 32
|
||||||
|
result += self._decrypt_chunk(buf)
|
||||||
|
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
""" Having accumulated a MAC, finish processing and return it.
|
||||||
|
Note that any unprocessed bytes are treated as if
|
||||||
|
they were encrypted zero bytes, so plaintext (zero) is accumulated. """
|
||||||
|
|
||||||
|
def finish(self, buf_len):
|
||||||
|
global KEYP, \
|
||||||
|
INITKONST
|
||||||
|
|
||||||
|
# handle any previously buffered bytes
|
||||||
|
if self._nbuf != 0:
|
||||||
|
# LFSR already cycled
|
||||||
|
self._macfunc(self._mbuf)
|
||||||
|
|
||||||
|
# perturb the MAC to mark end of input.
|
||||||
|
# Note that only the stream register is updated, not the CRC. This is an
|
||||||
|
# action that can't be duplicated by passing in plaintext, hence
|
||||||
|
# defeating any kind of extension attack.
|
||||||
|
self._cycle()
|
||||||
|
self._R[KEYP] ^= INITKONST ^ (self._nbuf << 3)
|
||||||
|
self._nbuf = 0
|
||||||
|
|
||||||
|
# now add the CRC to the stream register and diffuse it
|
||||||
|
for i in range(N):
|
||||||
|
self._R[i] ^= self._CRC[i]
|
||||||
|
|
||||||
|
self._diffuse()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
# produce output from the stream buffer
|
||||||
|
i = 0
|
||||||
|
for i in range(0, buf_len, 4):
|
||||||
|
self._cycle()
|
||||||
|
if i + 4 <= buf_len:
|
||||||
|
result += struct.pack("<I", self._sbuf)
|
||||||
|
else:
|
||||||
|
sbuf = self._sbuf
|
||||||
|
for j in range(i, buf_len):
|
||||||
|
result.append(sbuf & 0xFF)
|
||||||
|
sbuf >>= 8
|
||||||
|
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
TESTSIZE = 23
|
||||||
|
TEST_KEY = b"test key 128bits"
|
||||||
|
TEST_PHRASE = b'\x00' * 20
|
||||||
|
|
||||||
|
sh = Shannon(
|
||||||
|
bytes([
|
||||||
|
133, 199, 15, 101, 207, 100, 229, 237, 15, 249, 248, 155, 76, 170,
|
||||||
|
62, 189, 239, 251, 147, 213, 22, 186, 157, 47, 218, 198, 235, 14,
|
||||||
|
171, 50, 11, 121
|
||||||
|
]))
|
||||||
|
sh.set_nonce(0)
|
||||||
|
p1 = sh.decrypt(
|
||||||
|
bytes([
|
||||||
|
235,
|
||||||
|
94,
|
||||||
|
210,
|
||||||
|
19,
|
||||||
|
246,
|
||||||
|
203,
|
||||||
|
195,
|
||||||
|
35,
|
||||||
|
22,
|
||||||
|
215,
|
||||||
|
80,
|
||||||
|
69,
|
||||||
|
158,
|
||||||
|
247,
|
||||||
|
110,
|
||||||
|
146,
|
||||||
|
241,
|
||||||
|
101,
|
||||||
|
199,
|
||||||
|
37,
|
||||||
|
67,
|
||||||
|
92,
|
||||||
|
5,
|
||||||
|
197,
|
||||||
|
112,
|
||||||
|
244,
|
||||||
|
77,
|
||||||
|
185,
|
||||||
|
197,
|
||||||
|
118,
|
||||||
|
119,
|
||||||
|
56,
|
||||||
|
164,
|
||||||
|
246,
|
||||||
|
159,
|
||||||
|
242,
|
||||||
|
56,
|
||||||
|
200,
|
||||||
|
39,
|
||||||
|
27,
|
||||||
|
141,
|
||||||
|
191,
|
||||||
|
37,
|
||||||
|
244,
|
||||||
|
244,
|
||||||
|
164,
|
||||||
|
44,
|
||||||
|
250,
|
||||||
|
59,
|
||||||
|
227,
|
||||||
|
245,
|
||||||
|
155,
|
||||||
|
239,
|
||||||
|
155,
|
||||||
|
137,
|
||||||
|
85,
|
||||||
|
244,
|
||||||
|
29,
|
||||||
|
52,
|
||||||
|
233,
|
||||||
|
180,
|
||||||
|
119,
|
||||||
|
166,
|
||||||
|
46,
|
||||||
|
252,
|
||||||
|
24,
|
||||||
|
141,
|
||||||
|
20,
|
||||||
|
135,
|
||||||
|
73,
|
||||||
|
144,
|
||||||
|
10,
|
||||||
|
176,
|
||||||
|
79,
|
||||||
|
88,
|
||||||
|
228,
|
||||||
|
140,
|
||||||
|
62,
|
||||||
|
173,
|
||||||
|
192,
|
||||||
|
117,
|
||||||
|
116,
|
||||||
|
152,
|
||||||
|
182,
|
||||||
|
246,
|
||||||
|
183,
|
||||||
|
88,
|
||||||
|
90,
|
||||||
|
73,
|
||||||
|
51,
|
||||||
|
159,
|
||||||
|
83,
|
||||||
|
227,
|
||||||
|
222,
|
||||||
|
140,
|
||||||
|
48,
|
||||||
|
157,
|
||||||
|
137,
|
||||||
|
185,
|
||||||
|
131,
|
||||||
|
201,
|
||||||
|
202,
|
||||||
|
122,
|
||||||
|
112,
|
||||||
|
207,
|
||||||
|
231,
|
||||||
|
153,
|
||||||
|
155,
|
||||||
|
9,
|
||||||
|
163,
|
||||||
|
225,
|
||||||
|
73,
|
||||||
|
41,
|
||||||
|
252,
|
||||||
|
249,
|
||||||
|
65,
|
||||||
|
33,
|
||||||
|
102,
|
||||||
|
83,
|
||||||
|
100,
|
||||||
|
36,
|
||||||
|
115,
|
||||||
|
174,
|
||||||
|
191,
|
||||||
|
43,
|
||||||
|
250,
|
||||||
|
113,
|
||||||
|
229,
|
||||||
|
146,
|
||||||
|
47,
|
||||||
|
154,
|
||||||
|
175,
|
||||||
|
55,
|
||||||
|
101,
|
||||||
|
73,
|
||||||
|
164,
|
||||||
|
49,
|
||||||
|
234,
|
||||||
|
103,
|
||||||
|
32,
|
||||||
|
53,
|
||||||
|
190,
|
||||||
|
236,
|
||||||
|
47,
|
||||||
|
210,
|
||||||
|
78,
|
||||||
|
141,
|
||||||
|
0,
|
||||||
|
176,
|
||||||
|
255,
|
||||||
|
79,
|
||||||
|
151,
|
||||||
|
159,
|
||||||
|
66,
|
||||||
|
20,
|
||||||
|
]))
|
||||||
|
print([hex(x) for x in p1])
|
||||||
|
print([hex(x) for x in sh.finish(4)])
|
||||||
|
sh.set_nonce(1)
|
||||||
|
print([hex(x) for x in sh.decrypt(bytes([173, 184, 50]))])
|
||||||
|
|
||||||
|
sh = Shannon(TEST_KEY)
|
||||||
|
sh.set_nonce(0)
|
||||||
|
encr = [sh.encrypt(bytes([x])) for x in TEST_PHRASE]
|
||||||
|
print('Encrypted 1-by-1 (len %d)' % len(encr), [hex(x[0]) for x in encr])
|
||||||
|
print(' sbuf %08x' % sh._sbuf)
|
||||||
|
print(' MAC', [hex(x) for x in sh.finish(4)])
|
||||||
|
|
||||||
|
sh.set_nonce(0)
|
||||||
|
encr = sh.encrypt(TEST_PHRASE)
|
||||||
|
print('Encrypted whole (len %d)' % len(encr), [hex(x) for x in encr])
|
||||||
|
print(' sbuf %08x' % sh._sbuf)
|
||||||
|
print(' MAC', [hex(x) for x in sh.finish(4)])
|
||||||
|
|
||||||
|
sh.set_nonce(0)
|
||||||
|
print('Decrypted whole', [hex(x) for x in sh.decrypt(encr)])
|
||||||
|
print(' MAC', [hex(x) for x in sh.finish(4)])
|
||||||
|
|
||||||
|
sh.set_nonce(0)
|
||||||
|
decr = [sh.decrypt(bytes([x])) for x in encr]
|
||||||
|
print('Decrypted 1-by-1', [hex(x[0]) for x in decr])
|
||||||
|
print(' MAC', [hex(x) for x in sh.finish(4)])
|
|
@ -0,0 +1,311 @@
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
class Shannon:
|
||||||
|
n = 16
|
||||||
|
fold = n
|
||||||
|
initkonst = 0x6996c53a
|
||||||
|
keyp = 13
|
||||||
|
|
||||||
|
r: list
|
||||||
|
crc: list
|
||||||
|
initr: list
|
||||||
|
konst: int
|
||||||
|
sbuf: int
|
||||||
|
mbuf: int
|
||||||
|
nbuf: int
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.r = [0 for _ in range(self.n)]
|
||||||
|
self.crc = [0 for _ in range(self.n)]
|
||||||
|
self.initr = [0 for _ in range(self.n)]
|
||||||
|
|
||||||
|
def rotl(self, i: int, distance: int):
|
||||||
|
return ((i << distance) | (i >> (32 - distance))) & 0xffffffff
|
||||||
|
|
||||||
|
def sbox(self, i: int):
|
||||||
|
i ^= self.rotl(i, 5) | self.rotl(i, 7)
|
||||||
|
i ^= self.rotl(i, 19) | self.rotl(i, 22)
|
||||||
|
|
||||||
|
return i
|
||||||
|
|
||||||
|
def sbox2(self, i: int):
|
||||||
|
i ^= self.rotl(i, 7) | self.rotl(i, 22)
|
||||||
|
i ^= self.rotl(i, 5) | self.rotl(i, 19)
|
||||||
|
|
||||||
|
return i
|
||||||
|
|
||||||
|
def cycle(self):
|
||||||
|
t: int
|
||||||
|
|
||||||
|
t = self.r[12] ^ self.r[13] ^ self.konst
|
||||||
|
t = self.sbox(t) ^ self.rotl(self.r[0], 1)
|
||||||
|
|
||||||
|
for i in range(1, self.n):
|
||||||
|
self.r[i - 1] = self.r[i]
|
||||||
|
|
||||||
|
self.r[self.n - 1] = t
|
||||||
|
|
||||||
|
t = self.sbox2(self.r[2] ^ self.r[15])
|
||||||
|
self.r[0] ^= t
|
||||||
|
self.sbuf = t ^ self.r[8] ^ self.r[12]
|
||||||
|
|
||||||
|
def crc_func(self, i: int):
|
||||||
|
t: int
|
||||||
|
|
||||||
|
t = self.crc[0] ^ self.crc[2] ^ self.crc[15] ^ i
|
||||||
|
|
||||||
|
for j in range(1, self.n):
|
||||||
|
self.crc[j - 1] = self.crc[j]
|
||||||
|
|
||||||
|
self.crc[self.n - 1] = t
|
||||||
|
|
||||||
|
def mac_func(self, i: int):
|
||||||
|
self.crc_func(i)
|
||||||
|
|
||||||
|
self.r[self.keyp] ^= i
|
||||||
|
|
||||||
|
def init_state(self):
|
||||||
|
self.r[0] = 1
|
||||||
|
self.r[1] = 1
|
||||||
|
|
||||||
|
for i in range(2, self.n):
|
||||||
|
self.r[i] = self.r[i - 1] + self.r[i - 2]
|
||||||
|
|
||||||
|
self.konst = self.initkonst
|
||||||
|
|
||||||
|
def save_state(self):
|
||||||
|
for i in range(self.n):
|
||||||
|
self.initr[i] = self.r[i]
|
||||||
|
|
||||||
|
def reload_state(self):
|
||||||
|
for i in range(self.n):
|
||||||
|
self.r[i] = self.initr[i]
|
||||||
|
|
||||||
|
def gen_konst(self):
|
||||||
|
self.konst = self.r[0]
|
||||||
|
|
||||||
|
def add_key(self, k: int):
|
||||||
|
self.r[self.keyp] ^= k
|
||||||
|
|
||||||
|
def diffuse(self):
|
||||||
|
for i in range(self.fold):
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
def load_key(self, key: bytes):
|
||||||
|
extra = bytearray(4)
|
||||||
|
i: int
|
||||||
|
j: int
|
||||||
|
t: int
|
||||||
|
|
||||||
|
padding_size = int((len(key) + 3) / 4) * 4 - len(key)
|
||||||
|
key = key + (b"\x00" * padding_size) + struct.pack("<I", len(key))
|
||||||
|
|
||||||
|
for i in range(0, len(key), 4):
|
||||||
|
self.r[self.keyp] = \
|
||||||
|
self.r[self.keyp] ^ \
|
||||||
|
struct.unpack("<I", key[i: i + 4])[0]
|
||||||
|
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
for i in range(self.n):
|
||||||
|
self.crc[i] = self.r[i]
|
||||||
|
|
||||||
|
self.diffuse()
|
||||||
|
|
||||||
|
for i in range(self.n):
|
||||||
|
self.r[i] ^= self.crc[i]
|
||||||
|
|
||||||
|
def key(self, key: bytes):
|
||||||
|
self.init_state()
|
||||||
|
|
||||||
|
self.load_key(key)
|
||||||
|
|
||||||
|
self.gen_konst()
|
||||||
|
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
|
self.nbuf = 0
|
||||||
|
|
||||||
|
def nonce(self, nonce: bytes):
|
||||||
|
self.reload_state()
|
||||||
|
|
||||||
|
self.konst = self.initkonst
|
||||||
|
|
||||||
|
self.load_key(nonce)
|
||||||
|
|
||||||
|
self.gen_konst()
|
||||||
|
|
||||||
|
self.nbuf = 0
|
||||||
|
|
||||||
|
def encrypt(self, buffer: bytes, n: int = None):
|
||||||
|
if n is None:
|
||||||
|
return self.encrypt(buffer, len(buffer))
|
||||||
|
|
||||||
|
buffer = bytearray(buffer)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
j: int
|
||||||
|
t: int
|
||||||
|
|
||||||
|
if self.nbuf != 0:
|
||||||
|
while self.nbuf != 0 and n != 0:
|
||||||
|
self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf)
|
||||||
|
buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
self.nbuf -= 8
|
||||||
|
|
||||||
|
n -= 1
|
||||||
|
|
||||||
|
if self.nbuf != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mac_func(self.mbuf)
|
||||||
|
|
||||||
|
j = n & ~0x03
|
||||||
|
|
||||||
|
while i < j:
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
t = ((buffer[i + 3] & 0xFF) << 24) | \
|
||||||
|
((buffer[i + 2] & 0xFF) << 16) | \
|
||||||
|
((buffer[i + 1] & 0xFF) << 8) | \
|
||||||
|
(buffer[i] & 0xFF)
|
||||||
|
|
||||||
|
self.mac_func(t)
|
||||||
|
|
||||||
|
t ^= self.sbuf
|
||||||
|
|
||||||
|
buffer[i + 3] = (t >> 24) & 0xFF
|
||||||
|
buffer[i + 2] = (t >> 16) & 0xFF
|
||||||
|
buffer[i + 3] = (t >> 8) & 0xFF
|
||||||
|
buffer[i] = t & 0xFF
|
||||||
|
|
||||||
|
i += 4
|
||||||
|
|
||||||
|
n &= 0x03
|
||||||
|
|
||||||
|
if n != 0:
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
self.mbuf = 0
|
||||||
|
self.nbuf = 32
|
||||||
|
|
||||||
|
while self.nbuf != 0 and n != 0:
|
||||||
|
self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf)
|
||||||
|
buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
self.nbuf -= 8
|
||||||
|
|
||||||
|
n -= 1
|
||||||
|
return bytes(buffer)
|
||||||
|
|
||||||
|
def decrypt(self, buffer: bytes, n: int = None):
|
||||||
|
if n is None:
|
||||||
|
return self.decrypt(buffer, len(buffer))
|
||||||
|
|
||||||
|
buffer = bytearray(buffer)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
j: int
|
||||||
|
t: int
|
||||||
|
|
||||||
|
if self.nbuf != 0:
|
||||||
|
while self.nbuf != 0 and n != 0:
|
||||||
|
buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff
|
||||||
|
self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
self.nbuf -= 8
|
||||||
|
|
||||||
|
n -= 1
|
||||||
|
|
||||||
|
if self.nbuf != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.mac_func(self.mbuf)
|
||||||
|
|
||||||
|
j = n & ~0x03
|
||||||
|
|
||||||
|
while i < j:
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
t = ((buffer[i + 3] & 0xFF) << 24) | \
|
||||||
|
((buffer[i + 2] & 0xFF) << 16) | \
|
||||||
|
((buffer[i + 1] & 0xFF) << 8) | \
|
||||||
|
(buffer[i] & 0xFF)
|
||||||
|
|
||||||
|
t ^= self.sbuf
|
||||||
|
|
||||||
|
self.mac_func(t)
|
||||||
|
|
||||||
|
buffer[i + 3] = (t >> 24) & 0xFF
|
||||||
|
buffer[i + 2] = (t >> 16) & 0xFF
|
||||||
|
buffer[i + 1] = (t >> 8) & 0xFF
|
||||||
|
buffer[i] = t & 0xFF
|
||||||
|
|
||||||
|
i += 4
|
||||||
|
|
||||||
|
n &= 0x03
|
||||||
|
|
||||||
|
if n != 0:
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
self.mbuf = 0
|
||||||
|
self.nbuf = 32
|
||||||
|
|
||||||
|
while self.nbuf != 0 and n != 0:
|
||||||
|
buffer[i] ^= (self.sbuf >> (32 - self.nbuf)) & 0xff
|
||||||
|
self.mbuf ^= (buffer[i] & 0xff) << (32 - self.nbuf)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
self.nbuf -= 8
|
||||||
|
|
||||||
|
n -= 1
|
||||||
|
|
||||||
|
return bytes(buffer)
|
||||||
|
|
||||||
|
def finish(self, buffer: bytes, n: int = None):
|
||||||
|
if n is None:
|
||||||
|
return self.finish(buffer, len(buffer))
|
||||||
|
|
||||||
|
buffer = bytearray(buffer)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
j: int
|
||||||
|
|
||||||
|
if self.nbuf != 0:
|
||||||
|
self.mac_func(self.mbuf)
|
||||||
|
|
||||||
|
self.cycle()
|
||||||
|
self.add_key(self.initkonst ^ (self.nbuf << 3))
|
||||||
|
|
||||||
|
self.nbuf = 0
|
||||||
|
|
||||||
|
for j in range(self.n):
|
||||||
|
self.r[j] ^= self.crc[j]
|
||||||
|
|
||||||
|
self.diffuse()
|
||||||
|
|
||||||
|
while n > 0:
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
if n >= 4:
|
||||||
|
buffer[i + 3] = (self.sbuf >> 24) & 0xff
|
||||||
|
buffer[i + 2] = (self.sbuf >> 16) & 0xff
|
||||||
|
buffer[i + 1] = (self.sbuf >> 8) & 0xff
|
||||||
|
buffer[i] = self.sbuf & 0xff
|
||||||
|
|
||||||
|
n -= 4
|
||||||
|
i += 4
|
||||||
|
else:
|
||||||
|
for j in range(n):
|
||||||
|
buffer[i + j] = (self.sbuf >> (i * 8)) & 0xff
|
||||||
|
break
|
||||||
|
return bytes(buffer)
|
|
@ -0,0 +1,4 @@
|
||||||
|
from librespot.crypto.CipherPair import CipherPair
|
||||||
|
from librespot.crypto.DiffieHellman import DiffieHellman
|
||||||
|
from librespot.crypto.Packet import Packet
|
||||||
|
from librespot.crypto.Shannon import Shannon
|
|
@ -0,0 +1,129 @@
|
||||||
|
from librespot.core.ApResolver import ApResolver
|
||||||
|
from librespot.metadata import AlbumId, ArtistId, EpisodeId, TrackId, ShowId
|
||||||
|
from librespot.proto import Connect, Metadata
|
||||||
|
from librespot.standard import Closeable
|
||||||
|
from typing import Union
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient(Closeable):
|
||||||
|
_LOGGER: logging = logging.getLogger(__name__)
|
||||||
|
_session = None
|
||||||
|
_baseUrl: str = None
|
||||||
|
|
||||||
|
def __init__(self, session):
|
||||||
|
self._session = session
|
||||||
|
self._baseUrl = "https://{}".format(ApResolver.get_random_spclient())
|
||||||
|
|
||||||
|
def build_request(self, method: str, suffix: str,
|
||||||
|
headers: Union[None, dict[str, str]],
|
||||||
|
body: Union[None, bytes]) -> requests.PreparedRequest:
|
||||||
|
request = requests.PreparedRequest()
|
||||||
|
request.method = method
|
||||||
|
request.data = body
|
||||||
|
request.headers = {}
|
||||||
|
if headers is not None:
|
||||||
|
request.headers = headers
|
||||||
|
request.headers["Authorization"] = "Bearer {}".format(
|
||||||
|
self._session.tokens().get("playlist-read"))
|
||||||
|
request.url = self._baseUrl + suffix
|
||||||
|
return request
|
||||||
|
|
||||||
|
def send(self, method: str, suffix: str, headers: Union[None, dict[str,
|
||||||
|
str]],
|
||||||
|
body: Union[None, bytes]) -> requests.Response:
|
||||||
|
resp = self._session.client().send(
|
||||||
|
self.build_request(method, suffix, headers, body))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def put_connect_state(self, connection_id: str,
|
||||||
|
proto: Connect.PutStateRequest) -> None:
|
||||||
|
resp = self.send(
|
||||||
|
"PUT",
|
||||||
|
"/connect-state/v1/devices/{}".format(self._session.device_id()), {
|
||||||
|
"Content-Type": "application/protobuf",
|
||||||
|
"X-Spotify-Connection-Id": connection_id
|
||||||
|
}, proto.SerializeToString())
|
||||||
|
|
||||||
|
if resp.status_code == 413:
|
||||||
|
self._LOGGER.warning(
|
||||||
|
"PUT state payload is too large: {} bytes uncompressed.".
|
||||||
|
format(len(proto.SerializeToString())))
|
||||||
|
elif resp.status_code != 200:
|
||||||
|
self._LOGGER.warning("PUT state returned {}. headers: {}".format(
|
||||||
|
resp.status_code, resp.headers))
|
||||||
|
|
||||||
|
def get_metadata_4_track(self, track: TrackId) -> Metadata.Track:
|
||||||
|
resp = self.send("GET", "/metadata/4/track/{}".format(track.hex_id()),
|
||||||
|
None, None)
|
||||||
|
ApiClient.StatusCodeException.check_status(resp)
|
||||||
|
|
||||||
|
body = resp.content
|
||||||
|
if body is None:
|
||||||
|
raise RuntimeError()
|
||||||
|
proto = Metadata.Track()
|
||||||
|
proto.ParseFromString(body)
|
||||||
|
return proto
|
||||||
|
|
||||||
|
def get_metadata_4_episode(self, episode: EpisodeId) -> Metadata.Episode:
|
||||||
|
resp = self.send("GET",
|
||||||
|
"/metadata/4/episode/{}".format(episode.hex_id()),
|
||||||
|
None, None)
|
||||||
|
ApiClient.StatusCodeException.check_status(resp)
|
||||||
|
|
||||||
|
body = resp.content
|
||||||
|
if body is None:
|
||||||
|
raise IOError()
|
||||||
|
proto = Metadata.Episode()
|
||||||
|
proto.ParseFromString(body)
|
||||||
|
return proto
|
||||||
|
|
||||||
|
def get_metadata_4_album(self, album: AlbumId) -> Metadata.Album:
|
||||||
|
resp = self.send("GET", "/metadata/4/album/{}".format(album.hex_id()),
|
||||||
|
None, None)
|
||||||
|
ApiClient.StatusCodeException.check_status(resp)
|
||||||
|
|
||||||
|
body = resp.content
|
||||||
|
if body is None:
|
||||||
|
raise IOError()
|
||||||
|
proto = Metadata.Album()
|
||||||
|
proto.ParseFromString(body)
|
||||||
|
return proto
|
||||||
|
|
||||||
|
def get_metadata_4_artist(self, artist: ArtistId) -> Metadata.Artist:
|
||||||
|
resp = self.send("GET",
|
||||||
|
"/metadata/4/artist/{}".format(artist.hex_id()), None,
|
||||||
|
None)
|
||||||
|
ApiClient.StatusCodeException.check_status(resp)
|
||||||
|
|
||||||
|
body = resp.content
|
||||||
|
if body is None:
|
||||||
|
raise IOError()
|
||||||
|
proto = Metadata.Artist()
|
||||||
|
proto.ParseFromString(body)
|
||||||
|
return proto
|
||||||
|
|
||||||
|
def get_metadata_4_show(self, show: ShowId) -> Metadata.Show:
|
||||||
|
resp = self.send("GET", "/metadata/4/show/{}".format(show.hex_id()),
|
||||||
|
None, None)
|
||||||
|
ApiClient.StatusCodeException.check_status(resp)
|
||||||
|
|
||||||
|
body = resp.content
|
||||||
|
if body is None:
|
||||||
|
raise IOError()
|
||||||
|
proto = Metadata.Show()
|
||||||
|
proto.ParseFromString(body)
|
||||||
|
return proto
|
||||||
|
|
||||||
|
class StatusCodeException(IOError):
|
||||||
|
code: int
|
||||||
|
|
||||||
|
def __init__(self, resp: requests.Response):
|
||||||
|
super().__init__(resp.status_code)
|
||||||
|
self.code = resp.status_code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_status(resp: requests.Response) -> None:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ApiClient.StatusCodeException(resp)
|
|
@ -0,0 +1,19 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.standard.Closeable import Closeable
|
||||||
|
|
||||||
|
|
||||||
|
class DealerClient(Closeable):
|
||||||
|
def __init__(self, session):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_message_listener(self, listener: DealerClient.MessageListener,
|
||||||
|
*uris: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MessageListener:
|
||||||
|
def on_message(self, uri: str, headers: dict[str, str],
|
||||||
|
payload: bytes):
|
||||||
|
pass
|
|
@ -0,0 +1,2 @@
|
||||||
|
from librespot.dealer.ApiClient import ApResolver
|
||||||
|
from librespot.dealer.DealerClient import DealerClient
|
|
@ -0,0 +1,5 @@
|
||||||
|
class JsonMercuryRequest:
|
||||||
|
request = None
|
||||||
|
|
||||||
|
def __init__(self, request):
|
||||||
|
self.request = request
|
|
@ -0,0 +1,256 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.common import Utils
|
||||||
|
from librespot.core import Session, PacketsReceiver
|
||||||
|
from librespot.crypto import Packet
|
||||||
|
from librespot.mercury import JsonMercuryRequest, RawMercuryRequest, SubListener
|
||||||
|
from librespot.standard import BytesInputStream, BytesOutputStream, Closeable
|
||||||
|
from librespot.proto import Mercury, Pubsub
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import typing
|
||||||
|
|
||||||
|
|
||||||
|
class MercuryClient(PacketsReceiver.PacketsReceiver, Closeable):
|
||||||
|
_LOGGER: logging = logging.getLogger(__name__)
|
||||||
|
_MERCURY_REQUEST_TIMEOUT: int = 3
|
||||||
|
_seqHolder: int = 1
|
||||||
|
_seqHolderLock: threading.Condition = threading.Condition()
|
||||||
|
_callbacks: dict[int, Callback] = dict()
|
||||||
|
_removeCallbackLock: threading.Condition = threading.Condition()
|
||||||
|
_subscriptions: list[MercuryClient.InternalSubListener] = list()
|
||||||
|
_subscriptionsLock: threading.Condition = threading.Condition()
|
||||||
|
_partials: dict[int, bytes] = dict()
|
||||||
|
_session: Session = None
|
||||||
|
|
||||||
|
def __init__(self, session: Session):
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def subscribe(self, uri: str, listener: SubListener) -> None:
|
||||||
|
response = self.send_sync(RawMercuryRequest.sub(uri))
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(response)
|
||||||
|
|
||||||
|
if len(response.payload) > 0:
|
||||||
|
for payload in response.payload:
|
||||||
|
sub = Pubsub.Subscription()
|
||||||
|
sub.ParseFromString(payload)
|
||||||
|
self._subscriptions.append(
|
||||||
|
MercuryClient.InternalSubListener(sub.uri, listener, True))
|
||||||
|
else:
|
||||||
|
self._subscriptions.append(
|
||||||
|
MercuryClient.InternalSubListener(uri, listener, True))
|
||||||
|
|
||||||
|
self._LOGGER.debug("Subscribed successfully to {}!".format(uri))
|
||||||
|
|
||||||
|
def unsubscribe(self, uri) -> None:
|
||||||
|
response = self.send_sync(RawMercuryRequest.unsub(uri))
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(response)
|
||||||
|
|
||||||
|
for subscription in self._subscriptions:
|
||||||
|
if subscription.matches(uri):
|
||||||
|
self._subscriptions.remove(subscription)
|
||||||
|
break
|
||||||
|
self._LOGGER.debug("Unsubscribed successfully from {}!".format(uri))
|
||||||
|
|
||||||
|
def send_sync(self, request: RawMercuryRequest) -> MercuryClient.Response:
|
||||||
|
callback = MercuryClient.SyncCallback()
|
||||||
|
seq = self.send(request, callback)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = callback.wait_response()
|
||||||
|
if resp is None:
|
||||||
|
raise IOError(
|
||||||
|
"Request timeout out, {} passed, yet no response. seq: {}".
|
||||||
|
format(self._MERCURY_REQUEST_TIMEOUT, seq))
|
||||||
|
return resp
|
||||||
|
except queue.Empty as e:
|
||||||
|
raise IOError(e)
|
||||||
|
|
||||||
|
def send_sync_json(self, request: JsonMercuryRequest) -> typing.Any:
|
||||||
|
resp = self.send_sync(request.request)
|
||||||
|
if 200 <= resp.status_code < 300:
|
||||||
|
return json.loads(resp.payload[0])
|
||||||
|
else:
|
||||||
|
raise MercuryClient.MercuryException(resp)
|
||||||
|
|
||||||
|
def send(self, request: RawMercuryRequest, callback) -> int:
|
||||||
|
buffer = BytesOutputStream()
|
||||||
|
|
||||||
|
seq: int
|
||||||
|
with self._seqHolderLock:
|
||||||
|
seq = self._seqHolder
|
||||||
|
self._seqHolder += 1
|
||||||
|
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Send Mercury request, seq: {}, uri: {}, method: {}".format(
|
||||||
|
seq, request.header.uri, request.header.method))
|
||||||
|
|
||||||
|
buffer.write_short(4)
|
||||||
|
buffer.write_int(seq)
|
||||||
|
|
||||||
|
buffer.write_byte(1)
|
||||||
|
buffer.write_short(1 + len(request.payload))
|
||||||
|
|
||||||
|
header_bytes = request.header.SerializeToString()
|
||||||
|
buffer.write_short(len(header_bytes))
|
||||||
|
buffer.write(header_bytes)
|
||||||
|
|
||||||
|
for part in request.payload:
|
||||||
|
buffer.write_short(len(part))
|
||||||
|
buffer.write(part)
|
||||||
|
|
||||||
|
cmd = Packet.Type.for_method(request.header.method)
|
||||||
|
self._session.send(cmd, buffer.buffer)
|
||||||
|
|
||||||
|
self._callbacks[seq] = callback
|
||||||
|
return seq
|
||||||
|
|
||||||
|
def dispatch(self, packet: Packet) -> None:
|
||||||
|
payload = BytesInputStream(packet.payload)
|
||||||
|
seq_length = payload.read_short()
|
||||||
|
if seq_length == 2:
|
||||||
|
seq = payload.read_short()
|
||||||
|
elif seq_length == 4:
|
||||||
|
seq = payload.read_int()
|
||||||
|
elif seq_length == 8:
|
||||||
|
seq = payload.read_long()
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Unknown seq length: {}".format(seq_length))
|
||||||
|
|
||||||
|
flags = payload.read_byte()
|
||||||
|
parts = payload.read_short()
|
||||||
|
|
||||||
|
partial = self._partials.get(seq)
|
||||||
|
if partial is None or flags == 0:
|
||||||
|
partial = []
|
||||||
|
self._partials[seq] = partial
|
||||||
|
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Handling packet, cmd: 0x{}, seq: {}, flags: {}, parts: {}".format(
|
||||||
|
Utils.bytes_to_hex(packet.cmd), seq, flags, parts))
|
||||||
|
|
||||||
|
for i in range(parts):
|
||||||
|
size = payload.read_short()
|
||||||
|
buffer = payload.read(size)
|
||||||
|
partial.append(buffer)
|
||||||
|
self._partials[seq] = partial
|
||||||
|
|
||||||
|
if flags != b"\x01":
|
||||||
|
return
|
||||||
|
|
||||||
|
self._partials.pop(seq)
|
||||||
|
|
||||||
|
header = Mercury.Header()
|
||||||
|
header.ParseFromString(partial[0])
|
||||||
|
|
||||||
|
resp = MercuryClient.Response(header, partial)
|
||||||
|
|
||||||
|
if packet.is_cmd(Packet.Type.mercury_event):
|
||||||
|
dispatched = False
|
||||||
|
with self._subscriptionsLock:
|
||||||
|
for sub in self._subscriptions:
|
||||||
|
if sub.matches(header.uri):
|
||||||
|
sub.dispatch(resp)
|
||||||
|
dispatched = True
|
||||||
|
|
||||||
|
if not dispatched:
|
||||||
|
self._LOGGER.debug(
|
||||||
|
"Couldn't dispatch Mercury event seq: {}, uri: {}, code: {}, payload: {}"
|
||||||
|
.format(seq, header.uri, header.status_code, resp.payload))
|
||||||
|
elif packet.is_cmd(Packet.Type.mercury_req) or \
|
||||||
|
packet.is_cmd(Packet.Type.mercury_sub) or \
|
||||||
|
packet.is_cmd(Packet.Type.mercury_sub):
|
||||||
|
callback = self._callbacks.get(seq)
|
||||||
|
self._callbacks.pop(seq)
|
||||||
|
if callback is not None:
|
||||||
|
callback.response(resp)
|
||||||
|
else:
|
||||||
|
self._LOGGER.warning(
|
||||||
|
"Skipped Mercury response, seq: {}, uri: {}, code: {}".
|
||||||
|
format(seq, resp.uri, resp.status_code))
|
||||||
|
|
||||||
|
with self._removeCallbackLock:
|
||||||
|
self._removeCallbackLock.notify_all()
|
||||||
|
else:
|
||||||
|
self._LOGGER.warning(
|
||||||
|
"Couldn't handle packet, seq: {}, uri: {}, code: {}".format(
|
||||||
|
seq, header.uri, header.status_code))
|
||||||
|
|
||||||
|
def interested_in(self, uri: str, listener: SubListener) -> None:
|
||||||
|
self._subscriptions.append(
|
||||||
|
MercuryClient.InternalSubListener(uri, listener, False))
|
||||||
|
|
||||||
|
def not_interested_in(self, listener: SubListener) -> None:
|
||||||
|
try:
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
self._subscriptions.remove(listener)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if len(self._subscriptions) != 0:
|
||||||
|
for listener in self._subscriptions:
|
||||||
|
if listener.isSub:
|
||||||
|
self.unsubscribe(listener.uri)
|
||||||
|
else:
|
||||||
|
self.not_interested_in(listener.listener)
|
||||||
|
|
||||||
|
if len(self._callbacks) != 0:
|
||||||
|
with self._removeCallbackLock:
|
||||||
|
self._removeCallbackLock.wait(self._MERCURY_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
self._callbacks.clear()
|
||||||
|
|
||||||
|
class Callback:
|
||||||
|
def response(self, response) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SyncCallback(Callback):
|
||||||
|
_reference = queue.Queue()
|
||||||
|
|
||||||
|
def response(self, response) -> None:
|
||||||
|
self._reference.put(response)
|
||||||
|
self._reference.task_done()
|
||||||
|
|
||||||
|
def wait_response(self) -> typing.Any:
|
||||||
|
return self._reference.get(
|
||||||
|
timeout=MercuryClient._MERCURY_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
# class PubSubException(MercuryClient.MercuryException):
|
||||||
|
# pass
|
||||||
|
|
||||||
|
class InternalSubListener:
|
||||||
|
uri: str
|
||||||
|
listener: SubListener
|
||||||
|
isSub: bool
|
||||||
|
|
||||||
|
def __init__(self, uri: str, listener: SubListener, is_sub: bool):
|
||||||
|
self.uri = uri
|
||||||
|
self.listener = listener
|
||||||
|
self.isSub = is_sub
|
||||||
|
|
||||||
|
def matches(self, uri: str) -> bool:
|
||||||
|
return uri.startswith(self.uri)
|
||||||
|
|
||||||
|
def dispatch(self, resp: MercuryClient.Response) -> None:
|
||||||
|
self.listener.event(resp)
|
||||||
|
|
||||||
|
class MercuryException(Exception):
|
||||||
|
code: int
|
||||||
|
|
||||||
|
def __init__(self, response):
|
||||||
|
super("status: {}".format(response.status_code))
|
||||||
|
self.code = response.status_code
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
uri: str
|
||||||
|
payload: list[bytes]
|
||||||
|
status_code: int
|
||||||
|
|
||||||
|
def __init__(self, header: Mercury.Header, payload: list[bytes]):
|
||||||
|
self.uri = header.uri
|
||||||
|
self.status_code = header.status_code
|
||||||
|
self.payload = payload[1:]
|
|
@ -0,0 +1,18 @@
|
||||||
|
from librespot.mercury.JsonMercuryRequest import JsonMercuryRequest
|
||||||
|
from librespot.mercury.RawMercuryRequest import RawMercuryRequest
|
||||||
|
|
||||||
|
|
||||||
|
class MercuryRequests:
|
||||||
|
keymaster_client_id = "65b708073fc0480ea92a077233ca87bd"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_root_playlists(username: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def request_token(device_id, scope):
|
||||||
|
return JsonMercuryRequest(
|
||||||
|
RawMercuryRequest.get(
|
||||||
|
"hm://keymaster/token/authenticated?scope={}&client_id={}&device_id={}"
|
||||||
|
.format(scope, MercuryRequests.keymaster_client_id,
|
||||||
|
device_id)))
|
|
@ -0,0 +1,7 @@
|
||||||
|
class ProtobufMercuryRequest:
|
||||||
|
request = None
|
||||||
|
parser = None
|
||||||
|
|
||||||
|
def __init__(self, request, parser):
|
||||||
|
self.request = request
|
||||||
|
self.parser = parser
|
|
@ -0,0 +1,87 @@
|
||||||
|
from librespot.proto import Mercury
|
||||||
|
|
||||||
|
|
||||||
|
class RawMercuryRequest:
|
||||||
|
header: Mercury.Header
|
||||||
|
payload: list[bytes]
|
||||||
|
|
||||||
|
def __init__(self, header: Mercury.Header, payload: list[bytes]):
|
||||||
|
self.header = header
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sub(uri: str):
|
||||||
|
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||||
|
"SUB").build()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def unsub(uri: str):
|
||||||
|
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||||
|
"UNSUB").build()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(uri: str):
|
||||||
|
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||||
|
"GET").build()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send(uri: str, part: bytes):
|
||||||
|
return RawMercuryRequest.new_builder().set_uri(uri).add_payload_part(
|
||||||
|
part).set_method("SEND").build()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def post(uri: str, part: bytes):
|
||||||
|
return RawMercuryRequest.new_builder().set_uri(uri).set_method(
|
||||||
|
"POST").add_payload_part(part).build()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new_builder():
|
||||||
|
return RawMercuryRequest.Builder()
|
||||||
|
|
||||||
|
class Builder:
|
||||||
|
header_dict: dict
|
||||||
|
payload: list[bytes]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.header_dict = {}
|
||||||
|
self.payload = []
|
||||||
|
|
||||||
|
def set_uri(self, uri: str):
|
||||||
|
self.header_dict["uri"] = uri
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_content_type(self, content_type: str):
|
||||||
|
self.header_dict["content_type"] = content_type
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_method(self, method: str):
|
||||||
|
self.header_dict["method"] = method
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_user_field(self,
|
||||||
|
field: Mercury.UserField = None,
|
||||||
|
key: str = None,
|
||||||
|
value: str = None):
|
||||||
|
if field is None and (key is None or value is None):
|
||||||
|
return self
|
||||||
|
try:
|
||||||
|
self.header_dict["user_fields"]
|
||||||
|
except KeyError:
|
||||||
|
self.header_dict["user_fields"] = []
|
||||||
|
if field is not None:
|
||||||
|
self.header_dict["user_fields"].append(field)
|
||||||
|
if key is not None and value is not None:
|
||||||
|
self.header_dict["user_fields"].append(
|
||||||
|
Mercury.UserField(key=key, value=value.encode()))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_payload_part(self, part: bytes):
|
||||||
|
self.payload.append(part)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def add_protobuf_payload(self, msg):
|
||||||
|
return self.add_payload_part(msg)
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
return RawMercuryRequest(Mercury.Header(**self.header_dict),
|
||||||
|
self.payload)
|
|
@ -0,0 +1,7 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.mercury import MercuryClient
|
||||||
|
|
||||||
|
|
||||||
|
class SubListener:
|
||||||
|
def event(self, resp: MercuryClient.Response) -> None:
|
||||||
|
pass
|
|
@ -0,0 +1,6 @@
|
||||||
|
from librespot.mercury.JsonMercuryRequest import JsonMercuryRequest
|
||||||
|
from librespot.mercury.MercuryClient import MercuryClient
|
||||||
|
from librespot.mercury.MercuryRequests import MercuryRequests
|
||||||
|
from librespot.mercury.ProtobufMercuryRequest import ProtobufMercuryRequest
|
||||||
|
from librespot.mercury.RawMercuryRequest import RawMercuryRequest
|
||||||
|
from librespot.mercury.SubListener import SubListener
|
|
@ -0,0 +1,40 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from librespot.common import Base62, Utils
|
||||||
|
from librespot.metadata import SpotifyId
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumId(SpotifyId.SpotifyId):
|
||||||
|
_PATTERN = re.compile(r"spotify:album:(.{22})")
|
||||||
|
_BASE62 = Base62.create_instance_with_inverted_character_set()
|
||||||
|
_hexId: str
|
||||||
|
|
||||||
|
def __init__(self, hex_id: str):
|
||||||
|
self._hexId = hex_id.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str) -> AlbumId:
|
||||||
|
matcher = AlbumId._PATTERN.search(uri)
|
||||||
|
if matcher is not None:
|
||||||
|
album_id = matcher.group(1)
|
||||||
|
return AlbumId(
|
||||||
|
Utils.bytes_to_hex(AlbumId._BASE62.decode(album_id, 16)))
|
||||||
|
else:
|
||||||
|
raise TypeError("Not a Spotify album ID: {}.f".format(uri))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_base62(base62: str) -> AlbumId:
|
||||||
|
return AlbumId(
|
||||||
|
Utils.bytes_to_hex(AlbumId._BASE62.decode(base62, 16)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_hex(hex_str: str) -> AlbumId:
|
||||||
|
return AlbumId(hex_str)
|
||||||
|
|
||||||
|
def to_mercury_uri(self) -> str:
|
||||||
|
return "spotify:album:{}".format(
|
||||||
|
AlbumId._BASE62.encode(Utils.hex_to_bytes(self._hexId)))
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
return self._hexId
|
|
@ -0,0 +1,43 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.common import Base62, Utils
|
||||||
|
from librespot.metadata import SpotifyId
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class ArtistId(SpotifyId.SpotifyId):
|
||||||
|
_PATTERN = re.compile("spotify:artist:(.{22})")
|
||||||
|
_BASE62 = Base62.create_instance_with_inverted_character_set()
|
||||||
|
_hexId: str
|
||||||
|
|
||||||
|
def __init__(self, hex_id: str):
|
||||||
|
self._hexId = hex_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str) -> ArtistId:
|
||||||
|
matcher = ArtistId._PATTERN.search(uri)
|
||||||
|
if matcher is not None:
|
||||||
|
artist_id = matcher.group(1)
|
||||||
|
return ArtistId(
|
||||||
|
Utils.bytes_to_hex(ArtistId._BASE62.decode(
|
||||||
|
artist_id, 16)))
|
||||||
|
else:
|
||||||
|
raise TypeError("Not a Spotify artist ID: {}".format(uri))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_base62(base62: str) -> ArtistId:
|
||||||
|
return ArtistId(
|
||||||
|
Utils.bytes_to_hex(ArtistId._BASE62.decode(base62, 16)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_hex(hex_str: str) -> ArtistId:
|
||||||
|
return ArtistId(hex_str)
|
||||||
|
|
||||||
|
def to_mercury_uri(self) -> str:
|
||||||
|
return "hm://metadata/4/artist/{}".format(self._hexId)
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
return "spotify:artist:{}".format(
|
||||||
|
ArtistId._BASE62.encode(Utils.hex_to_bytes(self._hexId)))
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
return self._hexId
|
|
@ -0,0 +1,46 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.common import Utils
|
||||||
|
from librespot.metadata import SpotifyId
|
||||||
|
from librespot.metadata.PlayableId import PlayableId
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class EpisodeId(SpotifyId.SpotifyId, PlayableId):
|
||||||
|
_PATTERN = re.compile(r"spotify:episode:(.{22})")
|
||||||
|
_hexId: str
|
||||||
|
|
||||||
|
def __init__(self, hex_id: str):
|
||||||
|
self._hexId = hex_id.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str) -> EpisodeId:
|
||||||
|
matcher = EpisodeId._PATTERN.search(uri)
|
||||||
|
if matcher is not None:
|
||||||
|
episode_id = matcher.group(1)
|
||||||
|
return EpisodeId(
|
||||||
|
Utils.Utils.bytes_to_hex(
|
||||||
|
PlayableId.BASE62.decode(episode_id, 16)))
|
||||||
|
else:
|
||||||
|
TypeError("Not a Spotify episode ID: {}".format(uri))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_base62(base62: str) -> EpisodeId:
|
||||||
|
return EpisodeId(
|
||||||
|
Utils.Utils.bytes_to_hex(PlayableId.BASE62.decode(base62, 16)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_hex(hex_str: str) -> EpisodeId:
|
||||||
|
return EpisodeId(hex_str)
|
||||||
|
|
||||||
|
def to_mercury_uri(self) -> str:
|
||||||
|
return "hm://metadata/4/episode/{}".format(self._hexId)
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
return "Spotify:episode:{}".format(
|
||||||
|
PlayableId.BASE62.encode(Utils.Utils.hex_to_bytes(self._hexId)))
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
return self._hexId
|
||||||
|
|
||||||
|
def get_gid(self) -> bytes:
|
||||||
|
return Utils.Utils.hex_to_bytes(self._hexId)
|
|
@ -0,0 +1,40 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.common.Base62 import Base62
|
||||||
|
# from librespot.metadata import EpisodeId, TrackId, UnsupportedId
|
||||||
|
from librespot.proto.context_track_pb2 import ContextTrack
|
||||||
|
|
||||||
|
|
||||||
|
class PlayableId:
|
||||||
|
BASE62 = Base62.create_instance_with_inverted_character_set()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str) -> PlayableId:
|
||||||
|
pass
|
||||||
|
# if not PlayableId.is_supported(uri):
|
||||||
|
# return UnsupportedId(uri)
|
||||||
|
|
||||||
|
# if TrackId._PATTERN.search(uri) is not None:
|
||||||
|
# return TrackId.from_uri(uri)
|
||||||
|
# elif EpisodeId._PATTERN.search(uri) is not None:
|
||||||
|
# return EpisodeId.from_uri(uri)
|
||||||
|
# else:
|
||||||
|
# raise TypeError("Unknown uri: {}".format(uri))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_supported(uri: str):
|
||||||
|
return not uri.startswith("spotify:local:") and \
|
||||||
|
not uri == "spotify:delimiter" and \
|
||||||
|
not uri == "spotify:meta:delimiter"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def should_play(track: ContextTrack):
|
||||||
|
return track.metadata_or_default
|
||||||
|
|
||||||
|
def get_gid(self) -> bytes:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
pass
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.common import Base62, Utils
|
||||||
|
from librespot.metadata import SpotifyId
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class ShowId(SpotifyId.SpotifyId):
|
||||||
|
_PATTERN = re.compile("spotify:show:(.{22})")
|
||||||
|
_BASE62 = Base62.create_instance_with_inverted_character_set()
|
||||||
|
_hexId: str
|
||||||
|
|
||||||
|
def __init__(self, hex_id: str):
|
||||||
|
self._hexId = hex_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str) -> ShowId:
|
||||||
|
matcher = ShowId._PATTERN.search(uri)
|
||||||
|
if matcher is not None:
|
||||||
|
show_id = matcher.group(1)
|
||||||
|
return ShowId(
|
||||||
|
Utils.bytes_to_hex(ShowId._BASE62.decode(show_id, 16)))
|
||||||
|
else:
|
||||||
|
raise TypeError("Not a Spotify show ID: {}".format(uri))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_base62(base62: str) -> ShowId:
|
||||||
|
return ShowId(
|
||||||
|
Utils.bytes_to_hex(ShowId._BASE62.decode(base62, 16)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_hex(hex_str: str) -> ShowId:
|
||||||
|
return ShowId(hex_str)
|
||||||
|
|
||||||
|
def to_mercury_uri(self) -> str:
|
||||||
|
return "hm://metadata/4/show/{}".format(self._hexId)
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
return "spotify:show:{}".format(
|
||||||
|
ShowId._BASE62.encode(Utils.hex_to_bytes(self._hexId)))
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
return self._hexId
|
|
@ -0,0 +1,23 @@
|
||||||
|
class SpotifyId:
|
||||||
|
STATIC_FROM_URI = "fromUri"
|
||||||
|
STATIC_FROM_BASE62 = "fromBase62"
|
||||||
|
STATIC_FROM_HEX = "fromHex"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_base62(base62: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_hex(hex_str: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SpotifyIdParsingException(Exception):
|
||||||
|
def __init__(self, cause):
|
||||||
|
super().__init__(cause)
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
from librespot.common import Utils
|
||||||
|
from librespot.metadata import SpotifyId
|
||||||
|
from librespot.metadata.PlayableId import PlayableId
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class TrackId(PlayableId, SpotifyId):
|
||||||
|
_PATTERN = re.compile("spotify:track:(.{22})")
|
||||||
|
_hexId: str
|
||||||
|
|
||||||
|
def __init__(self, hex_id: str):
|
||||||
|
self._hexId = hex_id.lower()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_uri(uri: str) -> TrackId:
|
||||||
|
search = TrackId._PATTERN.search(uri)
|
||||||
|
if search is not None:
|
||||||
|
track_id = search.group(1)
|
||||||
|
return TrackId(
|
||||||
|
Utils.bytes_to_hex(PlayableId.BASE62.decode(
|
||||||
|
track_id, 16)))
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Not a Spotify track ID: {}".format(uri))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_base62(base62: str) -> TrackId:
|
||||||
|
return TrackId(
|
||||||
|
Utils.bytes_to_hex(PlayableId.BASE62.decode(base62, 16)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_hex(hex_str: str) -> TrackId:
|
||||||
|
return TrackId(hex_str)
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
return "spotify:track:{}".format(self._hexId)
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
return self._hexId
|
||||||
|
|
||||||
|
def get_gid(self) -> bytes:
|
||||||
|
return Utils.hex_to_bytes(self._hexId)
|
|
@ -0,0 +1,17 @@
|
||||||
|
from librespot.metadata import PlayableId
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedId(PlayableId):
|
||||||
|
uri: str
|
||||||
|
|
||||||
|
def __init__(self, uri: str):
|
||||||
|
self.uri = uri
|
||||||
|
|
||||||
|
def get_gid(self) -> bytes:
|
||||||
|
raise TypeError()
|
||||||
|
|
||||||
|
def hex_id(self) -> str:
|
||||||
|
raise TypeError()
|
||||||
|
|
||||||
|
def to_spotify_uri(self) -> str:
|
||||||
|
return self.uri
|
|
@ -0,0 +1,8 @@
|
||||||
|
from librespot.metadata.AlbumId import AlbumId
|
||||||
|
from librespot.metadata.ArtistId import ArtistId
|
||||||
|
from librespot.metadata.EpisodeId import EpisodeId
|
||||||
|
from librespot.metadata.PlayableId import PlayableId
|
||||||
|
from librespot.metadata.ShowId import SpotifyId
|
||||||
|
from librespot.metadata.SpotifyId import SpotifyId
|
||||||
|
from librespot.metadata.TrackId import TrackId
|
||||||
|
from librespot.metadata.UnsupportedId import UnsupportedId
|
|
@ -0,0 +1,21 @@
|
||||||
|
from librespot.core.Session import Session
|
||||||
|
from librespot.player import PlayerConfiguration, StateWrapper
|
||||||
|
import sched
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class Player:
|
||||||
|
VOLUME_MAX: int = 65536
|
||||||
|
_scheduler: sched.scheduler = sched.scheduler(time.time)
|
||||||
|
_session: Session = None
|
||||||
|
_conf: PlayerConfiguration
|
||||||
|
state: StateWrapper
|
||||||
|
|
||||||
|
# _playerSession:
|
||||||
|
|
||||||
|
def __init__(self, conf: PlayerConfiguration, session: Session):
|
||||||
|
self._conf = conf
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
def init_state(self):
|
||||||
|
self.state = StateWrapper(self._session, self, self._conf)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue