Initial Commit

This commit is contained in:
kokarare1212 2021-02-24 08:46:59 +09:00
parent 6bee6cd53a
commit 01dae8ada4
160 changed files with 29238 additions and 4 deletions

16
.gitignore vendored
View File

@ -20,7 +20,6 @@ parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
@ -50,6 +49,7 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
@ -72,6 +72,7 @@ instance/
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
@ -82,7 +83,9 @@ profile_default/
ipython_config.py
# 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
# 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/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# for PyCharm
.idea/

View File

@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2021 kokarare1212
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -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
## 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

98
example/console.py Normal file
View File

@ -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()

168
proto/authentication.proto Normal file
View File

@ -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;
}

14
proto/canvaz-meta.proto Normal file
View File

@ -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;
}

40
proto/canvaz.proto Normal file
View File

@ -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;
}
}

173
proto/connect.proto Normal file
View File

@ -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;
}

18
proto/context.proto Normal file
View File

@ -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;
}

16
proto/context_page.proto Normal file
View File

@ -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;
}

View File

@ -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;
}

13
proto/context_track.proto Normal file
View File

@ -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;
}

View File

@ -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;
}

231
proto/keyexchange.proto Normal file
View File

@ -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;
}

49
proto/mercury.proto Normal file
View File

@ -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;
}

279
proto/metadata.proto Normal file
View File

@ -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;
}

16
proto/play_origin.proto Normal file
View File

@ -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;
}

16
proto/playback.proto Normal file
View File

@ -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;
}

102
proto/player.proto Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

11
proto/pubsub.proto Normal file
View File

@ -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;
}

13
proto/queue.proto Normal file
View File

@ -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;
}

30
proto/restrictions.proto Normal file
View File

@ -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;
}

17
proto/session.proto Normal file
View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
defusedxml
protobuf
pycryptodome
requests

19
setup.py Normal file
View File

@ -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"
])

32
src/librespot/Version.py Normal file
View File

@ -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)

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
src/librespot/cache/CacheManager.py vendored Normal file
View File

@ -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

1
src/librespot/cache/__init__.py vendored Normal file
View File

@ -0,0 +1 @@
from librespot.cache.CacheManager import CacheManager

View File

@ -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'

View File

@ -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)

View File

@ -0,0 +1,2 @@
from librespot.common.Base62 import Base62
from librespot.common.Utils import Utils

View File

@ -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")

View File

@ -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()

View File

@ -0,0 +1,6 @@
from librespot.crypto.Packet import Packet
class PacketsReceiver:
def dispatch(self, packet: Packet):
pass

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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)])

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,2 @@
from librespot.dealer.ApiClient import ApResolver
from librespot.dealer.DealerClient import DealerClient

View File

@ -0,0 +1,5 @@
class JsonMercuryRequest:
request = None
def __init__(self, request):
self.request = request

View File

@ -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:]

View File

@ -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)))

View File

@ -0,0 +1,7 @@
class ProtobufMercuryRequest:
request = None
parser = None
def __init__(self, request, parser):
self.request = request
self.parser = parser

View File

@ -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)

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from librespot.mercury import MercuryClient
class SubListener:
def event(self, resp: MercuryClient.Response) -> None:
pass

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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