Initial commit: QobuzD - Qobuz Connect renderer for Linux
Rust-based QConnect renderer with: - QConnect WebSocket protocol (hand-rolled protobuf) - Audio playback via Symphonia + cpal - Play, pause, resume, volume, skip support - Correct BufferState/PlayingState enum values per proto spec - Server-driven queue management (no local queue) - Periodic position reporting for track-end detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.x86_64-unknown-linux-musl]
|
||||||
|
rustflags = ["-C", "target-feature=-crt-static"]
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
3599
Cargo.lock
generated
Normal file
3599
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
Cargo.toml
Normal file
44
Cargo.toml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
[package]
|
||||||
|
name = "qobuzd"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["QobuzD Team"]
|
||||||
|
description = "Qobuz Connect client for Linux"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls-webpki-roots", "stream", "blocking"], default-features = false }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
md-5 = "0.10"
|
||||||
|
hmac = "0.12"
|
||||||
|
sha2 = "0.10"
|
||||||
|
rand = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
directories = "5.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.0", features = ["derive"] }
|
||||||
|
keyring = "3"
|
||||||
|
zeroize = { version = "1.5", features = ["derive"] }
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
rand_chacha = "0.3"
|
||||||
|
sha1 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
tokio-tungstenite = { version = "0.24", features = ["native-tls", "native-tls-vendored"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
symphonia = { version = "0.5", features = ["flac", "pcm", "mp3", "aac", "ogg", "wav"] }
|
||||||
|
cpal = "0.15"
|
||||||
|
rubato = "0.15"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = "z"
|
||||||
174
PROTOCOL.md
Normal file
174
PROTOCOL.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# QConnect Protocol Reference
|
||||||
|
|
||||||
|
Reverse-engineered from Qobuz Android app (JADX decompilation) and Burp traffic analysis.
|
||||||
|
|
||||||
|
## Transport Layer
|
||||||
|
|
||||||
|
WebSocket binary frames with custom framing:
|
||||||
|
- Frame = `type_byte` + `varint(body_length)` + `body`
|
||||||
|
- Frame type 1 = Auth (field 1=msg_id, field 3=jwt)
|
||||||
|
- Frame type 2 = Subscribe (field 1=msg_id, field 3=1)
|
||||||
|
- Frame type 6 = Data payload (field 1=msg_id, field 2=timestamp, field 3=1, field 5=\x02, field 7=qconnect_container)
|
||||||
|
|
||||||
|
## QConnect Container (inside frame body field 7)
|
||||||
|
|
||||||
|
- Field 3 = serialized QConnect Message
|
||||||
|
|
||||||
|
## QConnect Message
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
message Message {
|
||||||
|
MessageType message_type = 1; // field number of payload == message_type value
|
||||||
|
oneof payload {
|
||||||
|
// Renderer -> Server
|
||||||
|
Renderer.JoinSessionMessage rndr_srvr_join_session = 21;
|
||||||
|
Renderer.StateUpdatedMessage rndr_srvr_state_updated = 23;
|
||||||
|
Renderer.VolumeChangedMessage rndr_srvr_volume_changed = 25;
|
||||||
|
|
||||||
|
// Server -> Renderer
|
||||||
|
Renderer.SetStateMessage srvr_rndr_set_state = 41;
|
||||||
|
Renderer.SetVolumeMessage srvr_rndr_set_volume = 42;
|
||||||
|
Renderer.SetActiveMessage srvr_rndr_set_active = 43;
|
||||||
|
Renderer.SetMaxAudioQualityMsg srvr_rndr_set_max_quality = 44;
|
||||||
|
Renderer.SetLoopModeMessage srvr_rndr_set_loop_mode = 45;
|
||||||
|
Renderer.SetShuffleModeMessage srvr_rndr_set_shuffle_mode = 46;
|
||||||
|
Renderer.MuteVolumeMessage srvr_rndr_mute_volume = 47;
|
||||||
|
|
||||||
|
// Controller -> Server
|
||||||
|
Controller.JoinSessionMessage ctrl_srvr_join_session = 61;
|
||||||
|
Controller.SetPlayerStateMsg ctrl_srvr_set_player_state = 62;
|
||||||
|
Controller.QueueLoadTracksMsg ctrl_srvr_queue_load_tracks= 66;
|
||||||
|
|
||||||
|
// Server -> Controller
|
||||||
|
// SessionStateMessage = 81;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Definitions
|
||||||
|
|
||||||
|
### Renderer.SetStateMessage (41 - SRVR_RNDR_SET_STATE)
|
||||||
|
```protobuf
|
||||||
|
message SetStateMessage {
|
||||||
|
PlayingState playing_state = 1; // enum: 0=UNKNOWN, 1=STOPPED, 2=PLAYING, 3=PAUSED
|
||||||
|
int32 current_position = 2; // playback position (seconds)
|
||||||
|
QueueVersion queue_version = 3; // sub-message (field 1 = major version)
|
||||||
|
QueueTrackWithContext current_track = 4;
|
||||||
|
QueueTrackWithContext next_track = 5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer.SetVolumeMessage (42 - SRVR_RNDR_SET_VOLUME)
|
||||||
|
```protobuf
|
||||||
|
message SetVolumeMessage {
|
||||||
|
int32 volume = 1; // absolute volume (0-100)
|
||||||
|
int32 volume_delta = 2; // relative change
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer.SetActiveMessage (43 - SRVR_RNDR_SET_ACTIVE)
|
||||||
|
```protobuf
|
||||||
|
message SetActiveMessage {
|
||||||
|
bool active = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common.QueueTrackWithContext
|
||||||
|
```protobuf
|
||||||
|
message QueueTrackWithContext {
|
||||||
|
int32 queue_item_id = 1;
|
||||||
|
int32 track_id = 2;
|
||||||
|
bytes context_uuid = 3; // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common.PlaybackPosition
|
||||||
|
```protobuf
|
||||||
|
message PlaybackPosition {
|
||||||
|
fixed64 timestamp = 1; // epoch millis
|
||||||
|
int32 value = 2; // position in seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RendererState (sent by renderer to report its state)
|
||||||
|
```protobuf
|
||||||
|
message RendererState {
|
||||||
|
PlayingState playing_state = 1;
|
||||||
|
BufferState buffer_state = 2; // enum: 0=UNKNOWN, 1=BUFFERING, 2=OK, 3=ERROR, 4=UNDERRUN
|
||||||
|
PlaybackPosition current_position = 3;
|
||||||
|
int32 duration = 4; // seconds
|
||||||
|
QueueVersion queue_version = 5; // sub-message (field 1 = major)
|
||||||
|
int32 current_queue_item_id = 6;
|
||||||
|
int32 next_queue_item_id = 7;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer.JoinSessionMessage (21 - RNDR_SRVR_JOIN_SESSION)
|
||||||
|
```protobuf
|
||||||
|
message JoinSessionMessage {
|
||||||
|
bytes session_uuid = 1;
|
||||||
|
DeviceInfo device_info = 2;
|
||||||
|
RendererState initial_state = 4;
|
||||||
|
bool is_active = 5;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer.StateUpdatedMessage (23 - RNDR_SRVR_STATE_UPDATED)
|
||||||
|
```protobuf
|
||||||
|
message StateUpdatedMessage {
|
||||||
|
RendererState state = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer.VolumeChangedMessage (25 - RNDR_SRVR_VOLUME_CHANGED)
|
||||||
|
```protobuf
|
||||||
|
message VolumeChangedMessage {
|
||||||
|
int32 volume = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controller.JoinSessionMessage (61 - CTRL_SRVR_JOIN_SESSION)
|
||||||
|
```protobuf
|
||||||
|
message JoinSessionMessage {
|
||||||
|
DeviceInfo device_info = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common.DeviceInfo
|
||||||
|
```protobuf
|
||||||
|
message DeviceInfo {
|
||||||
|
bytes device_uuid = 1;
|
||||||
|
string friendly_name = 2;
|
||||||
|
string brand = 3;
|
||||||
|
string model = 4;
|
||||||
|
string serial_number = 5;
|
||||||
|
DeviceType type = 6; // enum: 5=COMPUTER
|
||||||
|
Capabilities capabilities = 7;
|
||||||
|
string software_version = 8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common.Capabilities
|
||||||
|
```protobuf
|
||||||
|
message Capabilities {
|
||||||
|
int32 audio_quality = 1;
|
||||||
|
int32 field_2 = 2; // observed value: 4
|
||||||
|
int32 field_3 = 3; // observed value: 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PlayingState Enum
|
||||||
|
- 0 = UNKNOWN (idle sync, ignore)
|
||||||
|
- 1 = STOPPED
|
||||||
|
- 2 = PLAYING
|
||||||
|
- 3 = PAUSED
|
||||||
|
|
||||||
|
## Connection Flow
|
||||||
|
1. Get JWT + WS endpoint via `GET /api.json/0.2/qws/getToken`
|
||||||
|
2. Open ctrl WebSocket, send Auth (frame type 1) + Subscribe (frame type 2) + CTRL_SRVR_JOIN_SESSION (61)
|
||||||
|
3. Receive SRVR_CTRL_SESSION_STATE (81) containing session_uuid
|
||||||
|
4. Open renderer WebSocket, send Auth + Subscribe + RNDR_SRVR_JOIN_SESSION (21) with session_uuid
|
||||||
|
5. Receive SET_ACTIVE (43) confirming join
|
||||||
|
6. Enter main loop: handle SET_STATE (41), SET_VOLUME (42), etc.
|
||||||
|
7. Report state changes via STATE_UPDATED (23) and VOLUME_CHANGED (25)
|
||||||
|
8. Send heartbeat state updates every 5 seconds
|
||||||
172
decode_burp.py
Normal file
172
decode_burp.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Decode QConnect WebSocket messages from Burp capture."""
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
def decode_varint(data, pos):
|
||||||
|
val = 0; shift = 0
|
||||||
|
while pos < len(data):
|
||||||
|
b = data[pos]; pos += 1
|
||||||
|
val |= (b & 0x7F) << shift
|
||||||
|
if not (b & 0x80): return val, pos
|
||||||
|
shift += 7
|
||||||
|
return val, pos
|
||||||
|
|
||||||
|
def parse_fields(data):
|
||||||
|
fields = []; pos = 0
|
||||||
|
while pos < len(data):
|
||||||
|
if pos >= len(data): break
|
||||||
|
tag, pos = decode_varint(data, pos)
|
||||||
|
fnum = tag >> 3; wt = tag & 7
|
||||||
|
if wt == 0:
|
||||||
|
val, pos = decode_varint(data, pos)
|
||||||
|
fields.append((fnum, wt, val.to_bytes(8, 'little')))
|
||||||
|
elif wt == 1:
|
||||||
|
fields.append((fnum, wt, data[pos:pos+8])); pos += 8
|
||||||
|
elif wt == 2:
|
||||||
|
ln, pos = decode_varint(data, pos)
|
||||||
|
fields.append((fnum, wt, data[pos:pos+ln])); pos += ln
|
||||||
|
elif wt == 5:
|
||||||
|
fields.append((fnum, wt, data[pos:pos+4])); pos += 4
|
||||||
|
else: break
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def get_varint(fields, num):
|
||||||
|
for f, w, d in fields:
|
||||||
|
if f == num and w == 0:
|
||||||
|
return int.from_bytes(d[:8], 'little')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_bytes(fields, num):
|
||||||
|
for f, w, d in fields:
|
||||||
|
if f == num and w == 2: return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_fixed32(fields, num):
|
||||||
|
for f, w, d in fields:
|
||||||
|
if f == num and w == 5: return int.from_bytes(d[:4], 'little')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_fixed64(fields, num):
|
||||||
|
for f, w, d in fields:
|
||||||
|
if f == num and w == 1: return int.from_bytes(d[:8], 'little')
|
||||||
|
return None
|
||||||
|
|
||||||
|
MSG_NAMES = {
|
||||||
|
1: "ERROR", 21: "RNDR_JOIN", 23: "STATE_UPDATED", 25: "VOLUME_CHANGED",
|
||||||
|
28: "QUALITY_CHANGED", 29: "VOLUME_MUTED", 41: "SET_STATE", 42: "SET_VOLUME",
|
||||||
|
43: "SET_ACTIVE", 44: "SET_QUALITY", 45: "SET_LOOP", 46: "SET_SHUFFLE",
|
||||||
|
47: "MUTE_VOLUME", 61: "CTRL_JOIN", 66: "QUEUE_REQ", 67: "QUEUE_REQ2",
|
||||||
|
75: "QUEUE_UPDATE", 76: "SESSION_SETUP", 77: "ACK_JOIN", 79: "TRACK_LIST",
|
||||||
|
82: "SRV_STATE_ECHO", 83: "DEVICE_LIST", 86: "SRV_VOL_ECHO",
|
||||||
|
87: "SRV_VOL_ECHO2", 90: "SRV_QUEUE_INFO", 91: "SRV_QUEUE_RESP",
|
||||||
|
97: "SRV_ACK", 98: "SRV_ACK2", 99: "SRV_QUALITY_ECHO",
|
||||||
|
100: "SRV_QUALITY_INFO", 103: "SRV_TRACKLIST_RESP"
|
||||||
|
}
|
||||||
|
|
||||||
|
def decode_renderer_state(data):
|
||||||
|
"""Decode RendererState proto"""
|
||||||
|
f = parse_fields(data)
|
||||||
|
ps = get_varint(f, 1)
|
||||||
|
bs = get_varint(f, 2)
|
||||||
|
pos_data = get_bytes(f, 3)
|
||||||
|
dur = get_varint(f, 4)
|
||||||
|
qi = get_varint(f, 6)
|
||||||
|
nqi = get_varint(f, 7)
|
||||||
|
|
||||||
|
pos_ms = None; ts = None
|
||||||
|
if pos_data:
|
||||||
|
pf = parse_fields(pos_data)
|
||||||
|
ts = get_fixed64(pf, 1)
|
||||||
|
pos_ms = get_varint(pf, 2)
|
||||||
|
|
||||||
|
return f"playing={ps} buffer={bs} pos={pos_ms}ms dur={dur}ms qi={qi} nqi={nqi}"
|
||||||
|
|
||||||
|
def decode_set_state(data):
|
||||||
|
"""Decode SET_STATE (type 41) payload"""
|
||||||
|
f = parse_fields(data)
|
||||||
|
ps = get_varint(f, 1)
|
||||||
|
pos = get_varint(f, 2)
|
||||||
|
qv = get_bytes(f, 3)
|
||||||
|
ct = get_bytes(f, 4)
|
||||||
|
nt = get_bytes(f, 5)
|
||||||
|
|
||||||
|
qv_str = ""
|
||||||
|
if qv:
|
||||||
|
qvf = parse_fields(qv)
|
||||||
|
qv_str = f" qver={get_varint(qvf, 1)}.{get_varint(qvf, 2)}"
|
||||||
|
|
||||||
|
ct_str = ""
|
||||||
|
if ct:
|
||||||
|
ctf = parse_fields(ct)
|
||||||
|
ct_str = f" cur_track(qi={get_varint(ctf, 1)} tid={get_fixed32(ctf, 2)})"
|
||||||
|
|
||||||
|
nt_str = ""
|
||||||
|
if nt:
|
||||||
|
ntf = parse_fields(nt)
|
||||||
|
nt_str = f" next_track(qi={get_varint(ntf, 1)} tid={get_fixed32(ntf, 2)})"
|
||||||
|
|
||||||
|
return f"playing_state={'None' if ps is None else ps} pos={pos}{qv_str}{ct_str}{nt_str}"
|
||||||
|
|
||||||
|
def process_message(payload_str, direction):
|
||||||
|
data = payload_str.encode('latin-1')
|
||||||
|
if len(data) < 2: return
|
||||||
|
|
||||||
|
# Decode frame layer
|
||||||
|
pos = 0
|
||||||
|
while pos < len(data):
|
||||||
|
if pos >= len(data): break
|
||||||
|
ft = data[pos]; pos += 1
|
||||||
|
flen, pos = decode_varint(data, pos)
|
||||||
|
if pos + flen > len(data): break
|
||||||
|
fbody = data[pos:pos+flen]; pos += flen
|
||||||
|
|
||||||
|
if ft != 6: continue # only data frames
|
||||||
|
|
||||||
|
# Parse frame body fields
|
||||||
|
ff = parse_fields(fbody)
|
||||||
|
f7 = get_bytes(ff, 7)
|
||||||
|
if not f7: continue
|
||||||
|
|
||||||
|
# Inside f7, field 3 = QConnect message
|
||||||
|
cf = parse_fields(f7)
|
||||||
|
for cfnum, cwt, cdata in cf:
|
||||||
|
if cfnum != 3 or cwt != 2: continue
|
||||||
|
mf = parse_fields(cdata)
|
||||||
|
mt = get_varint(mf, 1)
|
||||||
|
if mt is None: continue
|
||||||
|
|
||||||
|
name = MSG_NAMES.get(mt, f"TYPE_{mt}")
|
||||||
|
payload = get_bytes(mf, mt)
|
||||||
|
|
||||||
|
extra = ""
|
||||||
|
if mt == 23 and payload: # STATE_UPDATED
|
||||||
|
# Unwrap field 1 (RendererState wrapper)
|
||||||
|
sf = parse_fields(payload)
|
||||||
|
state_data = get_bytes(sf, 1)
|
||||||
|
if state_data:
|
||||||
|
extra = " " + decode_renderer_state(state_data)
|
||||||
|
else:
|
||||||
|
extra = " " + decode_renderer_state(payload)
|
||||||
|
elif mt == 41 and payload: # SET_STATE
|
||||||
|
extra = " " + decode_set_state(payload)
|
||||||
|
elif mt == 82 and payload: # SRV_STATE_ECHO
|
||||||
|
sf = parse_fields(payload)
|
||||||
|
state_data = get_bytes(sf, 1)
|
||||||
|
if state_data:
|
||||||
|
extra = " " + decode_renderer_state(state_data)
|
||||||
|
|
||||||
|
arrow = ">>>" if "CLIENT" in direction else "<<<"
|
||||||
|
print(f" {arrow} {name}({mt}){extra}")
|
||||||
|
|
||||||
|
# Read from burp JSON
|
||||||
|
import subprocess
|
||||||
|
# Just process messages passed on stdin
|
||||||
|
for line in sys.stdin:
|
||||||
|
line = line.strip()
|
||||||
|
if not line: continue
|
||||||
|
try:
|
||||||
|
msg = json.loads(line)
|
||||||
|
d = msg.get('direction', '')
|
||||||
|
p = msg.get('payload', '')
|
||||||
|
process_message(p, d)
|
||||||
|
except: pass
|
||||||
445
src/api.rs
Normal file
445
src/api.rs
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
use crate::config::Config;
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::error::{QobuzError, Result};
|
||||||
|
use crate::types::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct QobuzApi {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
app_id: String,
|
||||||
|
device_id: String,
|
||||||
|
device_name: String,
|
||||||
|
session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QobuzApi {
|
||||||
|
pub fn new(config: &Config) -> Self {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.user_agent("Qobuzd/0.1.0")
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
base_url: "https://www.qobuz.com".to_string(),
|
||||||
|
app_id: config.app_id.clone(),
|
||||||
|
device_id: config.device_id.clone(),
|
||||||
|
device_name: config.device_name.clone(),
|
||||||
|
session_id: config.session_id.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_device_id(&self) -> &str {
|
||||||
|
&self.device_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_device_name(&self) -> &str {
|
||||||
|
&self.device_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_session_id(&self) -> &str {
|
||||||
|
&self.session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_timestamp(&self) -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_auth_headers(&self, access_token: Option<&str>) -> reqwest::header::HeaderMap {
|
||||||
|
use reqwest::header::*;
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("X-Device-Platform", "linux".parse().unwrap());
|
||||||
|
headers.insert("X-Device-Model", self.device_name.parse().unwrap());
|
||||||
|
headers.insert("X-Device-Manufacturer-Id", self.device_id.parse().unwrap());
|
||||||
|
|
||||||
|
if let Some(token) = access_token {
|
||||||
|
let auth_value = format!("Bearer {}", token);
|
||||||
|
headers.insert(AUTHORIZATION, auth_value.parse().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login(&self, email: &str, password: &str) -> Result<LoginResponse> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
let signature = crypto::generate_login_signature(email, password, &self.app_id, timestamp);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/oauth2/login?username={}&password={}&app_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url,
|
||||||
|
urlencoding::encode(email),
|
||||||
|
urlencoding::encode(password),
|
||||||
|
self.app_id,
|
||||||
|
timestamp,
|
||||||
|
signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717")
|
||||||
|
.header("X-App-Id", &self.app_id)
|
||||||
|
.header("X-App-Version", "9.7.0.3")
|
||||||
|
.header("X-Device-Platform", "android")
|
||||||
|
.header("X-Device-Model", "Nexus 6P")
|
||||||
|
.header("X-Device-Os-Version", "9")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error: ErrorResponse = response.json().await.unwrap_or_else(|_| ErrorResponse {
|
||||||
|
message: Some("Login failed".to_string()),
|
||||||
|
code: Some(status.as_u16() as u32),
|
||||||
|
status: Some("error".to_string()),
|
||||||
|
errors: None,
|
||||||
|
});
|
||||||
|
return Err(QobuzError::AuthError(
|
||||||
|
error.message.unwrap_or_else(|| "Unknown error".to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let login_response: LoginResponse = response.json().await?;
|
||||||
|
Ok(login_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_token(&self, refresh_token: &str) -> Result<OAuthTokens> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
let signature = crypto::generate_request_signature(
|
||||||
|
"oauth2/token",
|
||||||
|
&[
|
||||||
|
("refresh_token", refresh_token),
|
||||||
|
("grant_type", "refresh_token"),
|
||||||
|
],
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/oauth2/token?refresh_token={}&grant_type=refresh_token&app_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url,
|
||||||
|
urlencoding::encode(refresh_token),
|
||||||
|
self.app_id,
|
||||||
|
timestamp,
|
||||||
|
signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717")
|
||||||
|
.header("X-App-Id", &self.app_id)
|
||||||
|
.header("X-App-Version", "9.7.0.3")
|
||||||
|
.header("X-Device-Platform", "android")
|
||||||
|
.header("X-Device-Model", "Nexus 6P")
|
||||||
|
.header("X-Device-Os-Version", "9")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::AuthError("Token refresh failed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: OAuthTokens = response.json().await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user(&self, access_token: &str) -> Result<User> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/user/get?app_id={}",
|
||||||
|
self.base_url, self.app_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::ApiError("Failed to get user".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user: User = response.json().await?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_link_token(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
action: &str,
|
||||||
|
) -> Result<LinkTokenResponse> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
let signature = crypto::generate_request_signature(
|
||||||
|
"link/token",
|
||||||
|
&[
|
||||||
|
("link_action", action),
|
||||||
|
("external_device_id", &self.device_id),
|
||||||
|
],
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/link/token?app_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = LinkTokenRequest {
|
||||||
|
link_action: action.to_string(),
|
||||||
|
external_device_id: self.device_id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::LinkError(
|
||||||
|
"Failed to get link token".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let link_response: LinkTokenResponse = response.json().await?;
|
||||||
|
Ok(link_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_device_token(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
link_token: &str,
|
||||||
|
link_device_id: &str,
|
||||||
|
) -> Result<DeviceTokenResponse> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
let signature = crypto::generate_request_signature(
|
||||||
|
"link/device/token",
|
||||||
|
&[
|
||||||
|
("link_token", link_token),
|
||||||
|
("link_device_id", link_device_id),
|
||||||
|
("external_device_id", &self.device_id),
|
||||||
|
],
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/link/device/token?app_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = DeviceTokenRequest {
|
||||||
|
link_token: link_token.to_string(),
|
||||||
|
link_device_id: link_device_id.to_string(),
|
||||||
|
external_device_id: self.device_id.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::LinkError(
|
||||||
|
"Failed to get device token".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_response: DeviceTokenResponse = response.json().await?;
|
||||||
|
Ok(device_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_qws_token(&self, access_token: &str) -> Result<QwsTokenResponse> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
let signature = crypto::generate_request_signature("qws/createToken", &[], timestamp);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/qws/createToken?app_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.form(&[("jwt", "jwt_qws")])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::ApiError("Failed to get QWS token".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let qws_response: QwsTokenResponse = response.json().await?;
|
||||||
|
Ok(qws_response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_album(&self, access_token: &str, album_id: &str) -> Result<Album> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/album/get?app_id={}&album_id={}",
|
||||||
|
self.base_url, self.app_id, album_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::ApiError("Failed to get album".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let album: Album = response.json().await?;
|
||||||
|
Ok(album)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_track_url(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
track_id: &str,
|
||||||
|
format_id: u32,
|
||||||
|
) -> Result<String> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
let format_id_str = format_id.to_string();
|
||||||
|
|
||||||
|
let signature = crypto::generate_request_signature(
|
||||||
|
"track/getFileUrl",
|
||||||
|
&[
|
||||||
|
("format_id", &format_id_str),
|
||||||
|
("intent", "stream"),
|
||||||
|
("track_id", track_id),
|
||||||
|
],
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/track/getFileUrl?app_id={}&track_id={}&format_id={}&intent=stream&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, track_id, format_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(QobuzError::ApiError(format!(
|
||||||
|
"Failed to get track URL: {} - {}",
|
||||||
|
status, body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TrackUrlResponse {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let url_response: TrackUrlResponse = response.json().await?;
|
||||||
|
Ok(url_response.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_track(&self, access_token: &str, track_id: &str) -> Result<Track> {
|
||||||
|
let timestamp = self.get_timestamp();
|
||||||
|
|
||||||
|
let signature =
|
||||||
|
crypto::generate_request_signature("track/get", &[("track_id", track_id)], timestamp);
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/track/get?app_id={}&track_id={}&request_ts={}&request_sig={}",
|
||||||
|
self.base_url, self.app_id, track_id, timestamp, signature
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
return Err(QobuzError::ApiError(format!(
|
||||||
|
"Failed to get track: {} - {}",
|
||||||
|
status, body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let track: Track = response.json().await?;
|
||||||
|
Ok(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(
|
||||||
|
&self,
|
||||||
|
access_token: &str,
|
||||||
|
query: &str,
|
||||||
|
search_type: &str,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api.json/0.2/search?app_id={}&query={}&type={}&limit={}&offset={}",
|
||||||
|
self.base_url,
|
||||||
|
self.app_id,
|
||||||
|
urlencoding::encode(query),
|
||||||
|
search_type,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.headers(self.build_auth_headers(Some(access_token)))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(QobuzError::ApiError("Search failed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: serde_json::Value = response.json().await?;
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod urlencoding {
|
||||||
|
pub fn encode(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
for b in c.to_string().as_bytes() {
|
||||||
|
result.push_str(&format!("%{:02X}", b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/auth.rs
Normal file
144
src/auth.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
use crate::api::QobuzApi;
|
||||||
|
use crate::config::{Config, DeviceLinkCredentials};
|
||||||
|
use crate::error::{QobuzError, Result};
|
||||||
|
use crate::token::TokenManager;
|
||||||
|
use crate::types::{DeviceTokenResponse, LinkTokenResponse, User};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
pub struct QobuzAuth {
|
||||||
|
api: QobuzApi,
|
||||||
|
token_manager: TokenManager,
|
||||||
|
access_token: Arc<Mutex<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QobuzAuth {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let api = QobuzApi::new(&config);
|
||||||
|
let token_manager = TokenManager::new(config.clone());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
api,
|
||||||
|
token_manager,
|
||||||
|
access_token: Arc::new(Mutex::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_with_credentials(&self, email: &str, password: &str) -> Result<User> {
|
||||||
|
let response = self.api.login(email, password).await?;
|
||||||
|
|
||||||
|
let tokens = response.oauth;
|
||||||
|
self.token_manager.store_tokens(&tokens)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut token = self.access_token.lock().await;
|
||||||
|
*token = Some(tokens.access_token.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_access_token(&self) -> Result<()> {
|
||||||
|
let config =
|
||||||
|
crate::config::Config::load().map_err(|e| QobuzError::ConfigError(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(creds) = config.credentials {
|
||||||
|
let new_tokens = self.api.refresh_token(&creds.refresh_token).await?;
|
||||||
|
self.token_manager.store_tokens(&new_tokens)?;
|
||||||
|
|
||||||
|
let mut token = self.access_token.lock().await;
|
||||||
|
*token = Some(new_tokens.access_token);
|
||||||
|
} else {
|
||||||
|
return Err(QobuzError::TokenError(
|
||||||
|
"No refresh token available".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_valid_token(&self) -> Result<String> {
|
||||||
|
if let Some(token) = self.access_token.lock().await.clone() {
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tokens) = self.token_manager.load_tokens()? {
|
||||||
|
let mut token = self.access_token.lock().await;
|
||||||
|
*token = Some(tokens.access_token.clone());
|
||||||
|
return Ok(tokens.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(QobuzError::TokenError(
|
||||||
|
"No valid token available".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout(&self) -> Result<()> {
|
||||||
|
let mut token = self.access_token.lock().await;
|
||||||
|
*token = None;
|
||||||
|
|
||||||
|
let mut config =
|
||||||
|
crate::config::Config::load().map_err(|e| QobuzError::ConfigError(e.to_string()))?;
|
||||||
|
config.clear_credentials()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_device_linking(&self) -> Result<LinkTokenResponse> {
|
||||||
|
let token = self.get_valid_token().await?;
|
||||||
|
let response = self.api.get_link_token(&token, "signIn").await?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_device_token(
|
||||||
|
&self,
|
||||||
|
link_token: &str,
|
||||||
|
link_device_id: &str,
|
||||||
|
) -> Result<DeviceTokenResponse> {
|
||||||
|
let token = self.get_valid_token().await?;
|
||||||
|
let response = self
|
||||||
|
.api
|
||||||
|
.get_device_token(&token, link_token, link_device_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(oauth) = &response.oauth {
|
||||||
|
let creds = DeviceLinkCredentials {
|
||||||
|
device_access_token: oauth.access_token.clone(),
|
||||||
|
device_refresh_token: oauth.refresh_token.clone(),
|
||||||
|
device_id: self.api.get_device_id().to_string(),
|
||||||
|
link_device_id: link_device_id.to_string(),
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
self.token_manager.store_device_link_credentials(creds)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_device_link_status(
|
||||||
|
&self,
|
||||||
|
link_token: &str,
|
||||||
|
link_device_id: &str,
|
||||||
|
) -> Result<DeviceTokenResponse> {
|
||||||
|
self.get_device_token(link_token, link_device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_device_id(&self) -> &str {
|
||||||
|
self.api.get_device_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_device_name(&self) -> &str {
|
||||||
|
self.api.get_device_name()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_linked(&self) -> bool {
|
||||||
|
self.token_manager
|
||||||
|
.load_device_link_credentials()
|
||||||
|
.map(|c| c.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unlink_device(&self) -> Result<()> {
|
||||||
|
self.token_manager.clear_device_link_credentials()
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/config.rs
Normal file
102
src/config.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use crate::crypto;
|
||||||
|
use crate::error::{QobuzError, Result};
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub app_id: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub session_id: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub cache_dir: PathBuf,
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
pub credentials: Option<StoredCredentials>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoredCredentials {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub user_id: Option<u64>,
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceLinkCredentials {
|
||||||
|
pub device_access_token: String,
|
||||||
|
pub device_refresh_token: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub link_device_id: String,
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn new(device_name: String) -> Result<Self> {
|
||||||
|
let proj_dirs = ProjectDirs::from("com", "qobuz", "qobuzd").ok_or_else(|| {
|
||||||
|
QobuzError::ConfigError("Could not determine config directory".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let config_dir = proj_dirs.config_dir().to_path_buf();
|
||||||
|
let cache_dir = proj_dirs.cache_dir().to_path_buf();
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&config_dir)?;
|
||||||
|
std::fs::create_dir_all(&cache_dir)?;
|
||||||
|
|
||||||
|
let device_id = crypto::generate_device_id();
|
||||||
|
let session_id = crypto::generate_session_id();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
app_id: "312369995".to_string(),
|
||||||
|
device_id,
|
||||||
|
session_id,
|
||||||
|
device_name,
|
||||||
|
cache_dir,
|
||||||
|
config_dir,
|
||||||
|
credentials: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let proj_dirs = ProjectDirs::from("com", "qobuz", "qobuzd").ok_or_else(|| {
|
||||||
|
QobuzError::ConfigError("Could not determine config directory".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let config_path = proj_dirs.config_dir().join("config.json");
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
let content = std::fs::read_to_string(&config_path)?;
|
||||||
|
let config: Config = serde_json::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Self::new("qobuzd".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let config_path = self.config_dir.join("config.json");
|
||||||
|
let content = serde_json::to_string_pretty(self)?;
|
||||||
|
std::fs::write(config_path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_credentials(&mut self, creds: StoredCredentials) -> Result<()> {
|
||||||
|
self.credentials = Some(creds);
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_credentials(&mut self) -> Result<()> {
|
||||||
|
self.credentials = None;
|
||||||
|
self.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn credentials_path(&self) -> PathBuf {
|
||||||
|
self.config_dir.join("credentials.enc")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_link_credentials_path(&self) -> PathBuf {
|
||||||
|
self.config_dir.join("device_link.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/connect.proto
Normal file
145
src/connect.proto
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package qobuz.connect;
|
||||||
|
|
||||||
|
message DeviceInfo {
|
||||||
|
string device_id = 1;
|
||||||
|
string device_name = 2;
|
||||||
|
string device_type = 3;
|
||||||
|
string firmware_version = 4;
|
||||||
|
string ip_address = 5;
|
||||||
|
int32 port = 6;
|
||||||
|
Capabilities capabilities = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Capabilities {
|
||||||
|
bool supports_video = 1;
|
||||||
|
bool supports_audio = 2;
|
||||||
|
bool supports_image = 3;
|
||||||
|
repeated string supported_formats = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ControlMessage {
|
||||||
|
string message_id = 1;
|
||||||
|
MessageType type = 2;
|
||||||
|
oneof payload {
|
||||||
|
PlayRequest play = 10;
|
||||||
|
PauseRequest pause = 11;
|
||||||
|
StopRequest stop = 12;
|
||||||
|
SeekRequest seek = 13;
|
||||||
|
VolumeRequest volume = 14;
|
||||||
|
GetStatusRequest get_status = 15;
|
||||||
|
LoadRequest load = 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageType {
|
||||||
|
UNKNOWN = 0;
|
||||||
|
PLAY = 1;
|
||||||
|
PAUSE = 2;
|
||||||
|
STOP = 3;
|
||||||
|
SEEK = 4;
|
||||||
|
VOLUME = 5;
|
||||||
|
GET_STATUS = 6;
|
||||||
|
LOAD = 7;
|
||||||
|
STATUS = 100;
|
||||||
|
ERROR = 101;
|
||||||
|
CONNECTED = 102;
|
||||||
|
DISCONNECTED = 103;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PlayRequest {
|
||||||
|
string track_url = 1;
|
||||||
|
int64 position_ms = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PauseRequest {}
|
||||||
|
|
||||||
|
message StopRequest {}
|
||||||
|
|
||||||
|
message SeekRequest {
|
||||||
|
int64 position_ms = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VolumeRequest {
|
||||||
|
int32 volume = 1; // 0-100
|
||||||
|
bool mute = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetStatusRequest {}
|
||||||
|
|
||||||
|
message LoadRequest {
|
||||||
|
string track_id = 1;
|
||||||
|
string album_id = 2;
|
||||||
|
int32 format_id = 3;
|
||||||
|
int64 position_ms = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ControlResponse {
|
||||||
|
string message_id = 1;
|
||||||
|
MessageType type = 2;
|
||||||
|
bool success = 3;
|
||||||
|
string error_message = 4;
|
||||||
|
oneof payload {
|
||||||
|
StatusResponse status = 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message StatusResponse {
|
||||||
|
PlaybackState state = 1;
|
||||||
|
string track_id = 2;
|
||||||
|
string album_id = 3;
|
||||||
|
string track_url = 4;
|
||||||
|
int64 position_ms = 5;
|
||||||
|
int64 duration_ms = 6;
|
||||||
|
int32 volume = 7;
|
||||||
|
bool muted = 8;
|
||||||
|
TrackInfo track_info = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlaybackState {
|
||||||
|
IDLE = 0;
|
||||||
|
LOADING = 1;
|
||||||
|
PLAYING = 2;
|
||||||
|
PAUSED = 3;
|
||||||
|
BUFFERING = 4;
|
||||||
|
ERROR = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TrackInfo {
|
||||||
|
string title = 1;
|
||||||
|
string artist = 2;
|
||||||
|
string album = 3;
|
||||||
|
string album_artist = 4;
|
||||||
|
int32 track_number = 5;
|
||||||
|
int32 disc_number = 6;
|
||||||
|
int64 duration_ms = 7;
|
||||||
|
string artwork_url = 8;
|
||||||
|
string format = 9;
|
||||||
|
int32 bit_depth = 10;
|
||||||
|
int32 sample_rate = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LinkRequest {
|
||||||
|
string device_id = 1;
|
||||||
|
string device_name = 2;
|
||||||
|
string device_type = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LinkResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string link_token = 2;
|
||||||
|
string error_message = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StreamRequest {
|
||||||
|
string track_id = 1;
|
||||||
|
int32 format_id = 2;
|
||||||
|
int64 position_ms = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StreamResponse {
|
||||||
|
bool success = 1;
|
||||||
|
string stream_url = 2;
|
||||||
|
string error_message = 3;
|
||||||
|
}
|
||||||
87
src/crypto.rs
Normal file
87
src/crypto.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use md5::{Digest, Md5};
|
||||||
|
use sha1::Sha1;
|
||||||
|
|
||||||
|
const APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368";
|
||||||
|
|
||||||
|
pub fn md5_hash(input: &str) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
format!("{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha1_hash(input: &str) -> String {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_request_signature(
|
||||||
|
endpoint: &str,
|
||||||
|
params: &[(&str, &str)],
|
||||||
|
timestamp: i64,
|
||||||
|
) -> String {
|
||||||
|
let endpoint_clean = endpoint.replace("/", "");
|
||||||
|
|
||||||
|
let mut param_str = params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{}{}", k, v))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
param_str.sort();
|
||||||
|
|
||||||
|
let data = format!(
|
||||||
|
"{}{}{}{}",
|
||||||
|
endpoint_clean,
|
||||||
|
param_str.join(""),
|
||||||
|
timestamp,
|
||||||
|
APP_SECRET
|
||||||
|
);
|
||||||
|
md5_hash(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_login_signature(
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
_app_id: &str,
|
||||||
|
timestamp: i64,
|
||||||
|
) -> String {
|
||||||
|
generate_request_signature(
|
||||||
|
"oauth2/login",
|
||||||
|
&[("username", username), ("password", password)],
|
||||||
|
timestamp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_device_id() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let bytes: [u8; 16] = rng.gen();
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_session_id() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_client_id() -> String {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_md5_hash() {
|
||||||
|
let result = md5_hash("test");
|
||||||
|
assert_eq!(result, "098f6bcd4621d373cade4e832627b4f6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_request_signature() {
|
||||||
|
let sig = generate_request_signature("test", &[("a", "1"), ("b", "2")], 1234567890);
|
||||||
|
assert_eq!(sig.len(), 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/error.rs
Normal file
33
src/error.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum QobuzError {
|
||||||
|
#[error("Authentication failed: {0}")]
|
||||||
|
AuthError(String),
|
||||||
|
|
||||||
|
#[error("API request failed: {0}")]
|
||||||
|
ApiError(String),
|
||||||
|
|
||||||
|
#[error("Network error: {0}")]
|
||||||
|
NetworkError(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("JSON parse error: {0}")]
|
||||||
|
JsonError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
IoError(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Token error: {0}")]
|
||||||
|
TokenError(String),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
ConfigError(String),
|
||||||
|
|
||||||
|
#[error("Device linking error: {0}")]
|
||||||
|
LinkError(String),
|
||||||
|
|
||||||
|
#[error("Crypto error: {0}")]
|
||||||
|
CryptoError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, QobuzError>;
|
||||||
9
src/lib.rs
Normal file
9
src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod error;
|
||||||
|
pub mod player;
|
||||||
|
pub mod qconnect;
|
||||||
|
pub mod token;
|
||||||
|
pub mod types;
|
||||||
228
src/main.rs
Normal file
228
src/main.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info, Level};
|
||||||
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
|
use qobuzd::api::QobuzApi;
|
||||||
|
use qobuzd::auth::QobuzAuth;
|
||||||
|
use qobuzd::config::Config;
|
||||||
|
use qobuzd::qconnect::QConnect;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "qobuzd")]
|
||||||
|
#[command(about = "Qobuz Connect client for Linux")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short, long, default_value = "qobuzd")]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "info")]
|
||||||
|
log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
Login {
|
||||||
|
#[arg(short, long)]
|
||||||
|
email: String,
|
||||||
|
#[arg(short, long)]
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
Logout,
|
||||||
|
Status,
|
||||||
|
User,
|
||||||
|
Search {
|
||||||
|
#[arg(short, long)]
|
||||||
|
query: String,
|
||||||
|
#[arg(short, long, default_value = "albums")]
|
||||||
|
search_type: String,
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
#[arg(short, long)]
|
||||||
|
album_id: String,
|
||||||
|
},
|
||||||
|
Stream {
|
||||||
|
#[arg(short, long)]
|
||||||
|
track_id: String,
|
||||||
|
#[arg(short, long, default_value = "5")]
|
||||||
|
format_id: u32,
|
||||||
|
},
|
||||||
|
Serve,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let level = match cli.log_level.as_str() {
|
||||||
|
"debug" | "trace" => Level::DEBUG,
|
||||||
|
"warn" => Level::WARN,
|
||||||
|
"error" => Level::ERROR,
|
||||||
|
_ => Level::INFO,
|
||||||
|
};
|
||||||
|
tracing::subscriber::set_global_default(
|
||||||
|
FmtSubscriber::builder()
|
||||||
|
.with_max_level(level)
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(false)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.finish(),
|
||||||
|
)
|
||||||
|
.expect("failed to set subscriber");
|
||||||
|
|
||||||
|
let config = Config::new(cli.name.clone())?;
|
||||||
|
let auth = Arc::new(Mutex::new(QobuzAuth::new(config.clone())));
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Login { email, password } => {
|
||||||
|
println!("Logging in as {}...", email);
|
||||||
|
let auth_guard = auth.lock().await;
|
||||||
|
match auth_guard.login_with_credentials(&email, &password).await {
|
||||||
|
Ok(user) => {
|
||||||
|
println!(
|
||||||
|
"Logged in as: {} (id: {})",
|
||||||
|
user.display_name.unwrap_or_default(),
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Login failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Logout => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
guard.logout().await?;
|
||||||
|
println!("Logged out");
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Status => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
if guard.is_linked().await {
|
||||||
|
println!("Device is linked");
|
||||||
|
} else {
|
||||||
|
println!("Device is not linked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::User => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
let token = guard.get_valid_token().await?;
|
||||||
|
drop(guard);
|
||||||
|
let api = QobuzApi::new(&config);
|
||||||
|
match api.get_user(&token).await {
|
||||||
|
Ok(user) => {
|
||||||
|
println!("User: {}", user.display_name.unwrap_or_default());
|
||||||
|
println!("Email: {}", user.email);
|
||||||
|
if let Some(sub) = &user.subscription {
|
||||||
|
println!("Subscription: {}", sub.offer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Search { query, search_type } => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
let token = guard.get_valid_token().await?;
|
||||||
|
drop(guard);
|
||||||
|
let api = QobuzApi::new(&config);
|
||||||
|
match api.search(&token, &query, &search_type, 10, 0).await {
|
||||||
|
Ok(results) => {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&results)?);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Search failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Album { album_id } => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
let token = guard.get_valid_token().await?;
|
||||||
|
drop(guard);
|
||||||
|
let api = QobuzApi::new(&config);
|
||||||
|
match api.get_album(&token, &album_id).await {
|
||||||
|
Ok(album) => {
|
||||||
|
println!("Album: {}", album.title);
|
||||||
|
if let Some(artists) = &album.artists {
|
||||||
|
if let Some(a) = artists.first() {
|
||||||
|
println!("Artist: {}", a.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("Tracks: {}", album.track_count.unwrap_or(0));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Stream {
|
||||||
|
track_id,
|
||||||
|
format_id,
|
||||||
|
} => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
let token = guard.get_valid_token().await?;
|
||||||
|
drop(guard);
|
||||||
|
let api = QobuzApi::new(&config);
|
||||||
|
match api.get_track_url(&token, &track_id, format_id).await {
|
||||||
|
Ok(url) => println!("Stream URL: {}", url),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Serve => {
|
||||||
|
let guard = auth.lock().await;
|
||||||
|
let token = match guard.get_valid_token().await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Not logged in: {}", e);
|
||||||
|
println!("Run 'qobuzd login' first.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
let device_id = config.device_id.clone();
|
||||||
|
let device_name = config.device_name.clone();
|
||||||
|
|
||||||
|
println!("Starting QobuzD as '{}'...", device_name);
|
||||||
|
|
||||||
|
let mut qconnect = QConnect::start(token, device_id, device_name);
|
||||||
|
|
||||||
|
println!("QobuzD is running. Select it in the Qobuz app to play music.");
|
||||||
|
println!("Press Ctrl+C to stop.");
|
||||||
|
|
||||||
|
// Just forward commands to stdout for visibility
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if let Some(cmd) = qconnect.poll_command() {
|
||||||
|
info!("Command received: {:?}", cmd);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::signal::ctrl_c().await?;
|
||||||
|
println!("\nStopped.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
468
src/player.rs
Normal file
468
src/player.rs
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
use std::io::{self, Read, Seek, SeekFrom};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, AtomicU8, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use symphonia::core::audio::SampleBuffer;
|
||||||
|
use symphonia::core::codecs::DecoderOptions;
|
||||||
|
use symphonia::core::formats::FormatOptions;
|
||||||
|
use symphonia::core::io::{MediaSource, MediaSourceStream};
|
||||||
|
use symphonia::core::meta::MetadataOptions;
|
||||||
|
use symphonia::core::probe::Hint;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlayerCommand {
|
||||||
|
Play {
|
||||||
|
url: String,
|
||||||
|
track_id: i32,
|
||||||
|
queue_item_id: i32,
|
||||||
|
duration_ms: u64,
|
||||||
|
},
|
||||||
|
Resume,
|
||||||
|
Pause,
|
||||||
|
Stop,
|
||||||
|
SetVolume(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum PlayerState {
|
||||||
|
Stopped,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlayerStatus {
|
||||||
|
pub state: PlayerState,
|
||||||
|
pub position_ms: u64,
|
||||||
|
pub duration_ms: u64,
|
||||||
|
pub track_id: i32,
|
||||||
|
pub queue_item_id: i32,
|
||||||
|
pub volume: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharedState {
|
||||||
|
playing: AtomicBool,
|
||||||
|
paused: AtomicBool,
|
||||||
|
stop_signal: AtomicBool,
|
||||||
|
generation: AtomicU64, // incremented on each Play, used to avoid old threads clobbering state
|
||||||
|
position_ms: AtomicU64,
|
||||||
|
duration_ms: AtomicU64,
|
||||||
|
volume: AtomicU8,
|
||||||
|
track_id: AtomicI32,
|
||||||
|
queue_item_id: AtomicI32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AudioPlayer {
|
||||||
|
cmd_tx: mpsc::UnboundedSender<PlayerCommand>,
|
||||||
|
shared: Arc<SharedState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioPlayer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let shared = Arc::new(SharedState {
|
||||||
|
playing: AtomicBool::new(false),
|
||||||
|
paused: AtomicBool::new(false),
|
||||||
|
stop_signal: AtomicBool::new(false),
|
||||||
|
generation: AtomicU64::new(0),
|
||||||
|
position_ms: AtomicU64::new(0),
|
||||||
|
duration_ms: AtomicU64::new(0),
|
||||||
|
volume: AtomicU8::new(100),
|
||||||
|
track_id: AtomicI32::new(0),
|
||||||
|
queue_item_id: AtomicI32::new(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<PlayerCommand>();
|
||||||
|
let shared_clone = shared.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
player_thread(cmd_rx, shared_clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { cmd_tx, shared }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send(&self, cmd: PlayerCommand) {
|
||||||
|
let _ = self.cmd_tx.send(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> PlayerStatus {
|
||||||
|
let playing = self.shared.playing.load(Ordering::Relaxed);
|
||||||
|
let paused = self.shared.paused.load(Ordering::Relaxed);
|
||||||
|
let state = if playing && !paused {
|
||||||
|
PlayerState::Playing
|
||||||
|
} else if playing && paused {
|
||||||
|
PlayerState::Paused
|
||||||
|
} else {
|
||||||
|
PlayerState::Stopped
|
||||||
|
};
|
||||||
|
|
||||||
|
PlayerStatus {
|
||||||
|
state,
|
||||||
|
position_ms: self.shared.position_ms.load(Ordering::Relaxed),
|
||||||
|
duration_ms: self.shared.duration_ms.load(Ordering::Relaxed),
|
||||||
|
track_id: self.shared.track_id.load(Ordering::Relaxed),
|
||||||
|
queue_item_id: self.shared.queue_item_id.load(Ordering::Relaxed),
|
||||||
|
volume: self.shared.volume.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_thread(mut cmd_rx: mpsc::UnboundedReceiver<PlayerCommand>, shared: Arc<SharedState>) {
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build tokio runtime for player");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let cmd = match rt.block_on(cmd_rx.recv()) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
PlayerCommand::Play {
|
||||||
|
url,
|
||||||
|
track_id,
|
||||||
|
queue_item_id,
|
||||||
|
duration_ms,
|
||||||
|
} => {
|
||||||
|
// Stop any current playback
|
||||||
|
shared.stop_signal.store(true, Ordering::SeqCst);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
shared.stop_signal.store(false, Ordering::SeqCst);
|
||||||
|
let gen = shared.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
shared.paused.store(false, Ordering::SeqCst);
|
||||||
|
shared.position_ms.store(0, Ordering::SeqCst);
|
||||||
|
shared.duration_ms.store(duration_ms, Ordering::SeqCst);
|
||||||
|
shared.track_id.store(track_id, Ordering::SeqCst);
|
||||||
|
shared.queue_item_id.store(queue_item_id, Ordering::SeqCst);
|
||||||
|
shared.playing.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
let shared_play = shared.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = play_stream(&url, shared_play, gen) {
|
||||||
|
error!("Playback error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
PlayerCommand::Pause => {
|
||||||
|
shared.paused.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
PlayerCommand::Resume => {
|
||||||
|
shared.paused.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
PlayerCommand::Stop => {
|
||||||
|
shared.stop_signal.store(true, Ordering::SeqCst);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
shared.stop_signal.store(false, Ordering::SeqCst);
|
||||||
|
shared.playing.store(false, Ordering::SeqCst);
|
||||||
|
shared.paused.store(false, Ordering::SeqCst);
|
||||||
|
shared.track_id.store(0, Ordering::SeqCst);
|
||||||
|
shared.queue_item_id.store(0, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
PlayerCommand::SetVolume(vol) => {
|
||||||
|
shared.volume.store(vol, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP streaming source (streams from network, buffers first 512KB for seeks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HEAD_SIZE: usize = 512 * 1024;
|
||||||
|
|
||||||
|
struct HttpStreamSource {
|
||||||
|
reader: reqwest::blocking::Response,
|
||||||
|
head: Vec<u8>,
|
||||||
|
reader_pos: u64,
|
||||||
|
pos: u64,
|
||||||
|
content_length: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpStreamSource {
|
||||||
|
fn new(response: reqwest::blocking::Response, content_length: Option<u64>) -> Self {
|
||||||
|
Self {
|
||||||
|
reader: response,
|
||||||
|
head: Vec::new(),
|
||||||
|
reader_pos: 0,
|
||||||
|
pos: 0,
|
||||||
|
content_length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for HttpStreamSource {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
let pos = self.pos as usize;
|
||||||
|
|
||||||
|
if pos < self.head.len() {
|
||||||
|
let avail = self.head.len() - pos;
|
||||||
|
let n = buf.len().min(avail);
|
||||||
|
buf[..n].copy_from_slice(&self.head[pos..pos + n]);
|
||||||
|
self.pos += n as u64;
|
||||||
|
return Ok(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = self.reader.read(buf)?;
|
||||||
|
if n > 0 {
|
||||||
|
if self.reader_pos < HEAD_SIZE as u64 {
|
||||||
|
let capacity: usize = HEAD_SIZE.saturating_sub(self.head.len());
|
||||||
|
let to_buf = n.min(capacity);
|
||||||
|
if to_buf > 0 {
|
||||||
|
self.head.extend_from_slice(&buf[..to_buf]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader_pos += n as u64;
|
||||||
|
self.pos += n as u64;
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Seek for HttpStreamSource {
|
||||||
|
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||||
|
let cl = self.content_length.unwrap_or(u64::MAX);
|
||||||
|
let target: u64 = match from {
|
||||||
|
SeekFrom::Start(n) => n,
|
||||||
|
SeekFrom::End(n) if n < 0 => cl.saturating_sub((-n) as u64),
|
||||||
|
SeekFrom::End(_) => cl,
|
||||||
|
SeekFrom::Current(n) if n >= 0 => self.pos.saturating_add(n as u64),
|
||||||
|
SeekFrom::Current(n) => self.pos.saturating_sub((-n) as u64),
|
||||||
|
};
|
||||||
|
|
||||||
|
if target == self.pos {
|
||||||
|
return Ok(self.pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if target < self.reader_pos {
|
||||||
|
if target < self.head.len() as u64 {
|
||||||
|
self.pos = target;
|
||||||
|
return Ok(self.pos);
|
||||||
|
}
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"backward seek past head buffer",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward seek: read and discard
|
||||||
|
let mut remaining = target - self.reader_pos;
|
||||||
|
while remaining > 0 {
|
||||||
|
let mut discard = [0u8; 8192];
|
||||||
|
let want = (remaining as usize).min(discard.len());
|
||||||
|
match self.reader.read(&mut discard[..want]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if self.reader_pos < HEAD_SIZE as u64 {
|
||||||
|
let capacity: usize = HEAD_SIZE.saturating_sub(self.head.len());
|
||||||
|
let to_buf = n.min(capacity);
|
||||||
|
if to_buf > 0 {
|
||||||
|
self.head.extend_from_slice(&discard[..to_buf]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.reader_pos += n as u64;
|
||||||
|
remaining -= n as u64;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.pos = self.reader_pos;
|
||||||
|
Ok(self.pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MediaSource for HttpStreamSource {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_len(&self) -> Option<u64> {
|
||||||
|
self.content_length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streaming playback
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn play_stream(url: &str, shared: Arc<SharedState>, generation: u64) -> anyhow::Result<()> {
|
||||||
|
info!("Streaming audio...");
|
||||||
|
let response = reqwest::blocking::get(url)?;
|
||||||
|
let content_length = response.content_length();
|
||||||
|
let source = HttpStreamSource::new(response, content_length);
|
||||||
|
let mss = MediaSourceStream::new(Box::new(source), Default::default());
|
||||||
|
|
||||||
|
let hint = Hint::new();
|
||||||
|
let probed = symphonia::default::get_probe().format(
|
||||||
|
&hint,
|
||||||
|
mss,
|
||||||
|
&FormatOptions::default(),
|
||||||
|
&MetadataOptions::default(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut format = probed.format;
|
||||||
|
let track = format
|
||||||
|
.tracks()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no audio track"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let track_id = track.id;
|
||||||
|
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
|
||||||
|
let channels = track.codec_params.channels.map(|c| c.count()).unwrap_or(2);
|
||||||
|
|
||||||
|
// Update duration from codec if available (and not already set from API)
|
||||||
|
if shared.duration_ms.load(Ordering::Relaxed) == 0 {
|
||||||
|
if let Some(n_frames) = track.codec_params.n_frames {
|
||||||
|
let dur_ms = (n_frames as f64 / sample_rate as f64 * 1000.0) as u64;
|
||||||
|
shared.duration_ms.store(dur_ms, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut decoder =
|
||||||
|
symphonia::default::get_codecs().make(&track.codec_params, &DecoderOptions::default())?;
|
||||||
|
|
||||||
|
// Set up cpal output
|
||||||
|
let host = cpal::default_host();
|
||||||
|
let device = host
|
||||||
|
.default_output_device()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no audio output device"))?;
|
||||||
|
info!("Audio output: {}", device.name().unwrap_or_default());
|
||||||
|
|
||||||
|
let (sample_tx, sample_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(32);
|
||||||
|
|
||||||
|
let config = cpal::StreamConfig {
|
||||||
|
channels: channels as u16,
|
||||||
|
sample_rate: cpal::SampleRate(sample_rate),
|
||||||
|
buffer_size: cpal::BufferSize::Default,
|
||||||
|
};
|
||||||
|
|
||||||
|
let shared_out = shared.clone();
|
||||||
|
let mut ring_buf: Vec<f32> = Vec::new();
|
||||||
|
let mut ring_pos = 0;
|
||||||
|
|
||||||
|
let stream = device.build_output_stream(
|
||||||
|
&config,
|
||||||
|
move |out: &mut [f32], _: &cpal::OutputCallbackInfo| {
|
||||||
|
let vol = shared_out.volume.load(Ordering::Relaxed) as f32 / 100.0;
|
||||||
|
let paused = shared_out.paused.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
for sample in out.iter_mut() {
|
||||||
|
if paused {
|
||||||
|
*sample = 0.0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ring_pos >= ring_buf.len() {
|
||||||
|
match sample_rx.try_recv() {
|
||||||
|
Ok(buf) => {
|
||||||
|
ring_buf = buf;
|
||||||
|
ring_pos = 0;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
*sample = 0.0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*sample = ring_buf[ring_pos] * vol;
|
||||||
|
ring_pos += 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|err| error!("cpal error: {}", err),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
stream.play()?;
|
||||||
|
info!("Playback started ({}Hz, {}ch)", sample_rate, channels);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Check if superseded by a newer Play command (generation changed)
|
||||||
|
if shared.generation.load(Ordering::SeqCst) != generation {
|
||||||
|
info!("Playback superseded by newer generation");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if shared.stop_signal.load(Ordering::Relaxed) {
|
||||||
|
info!("Playback stopped by signal");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
while shared.paused.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
if shared.stop_signal.load(Ordering::Relaxed)
|
||||||
|
|| shared.generation.load(Ordering::SeqCst) != generation
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if shared.stop_signal.load(Ordering::Relaxed)
|
||||||
|
|| shared.generation.load(Ordering::SeqCst) != generation
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let packet = match format.next_packet() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(symphonia::core::errors::Error::IoError(ref e))
|
||||||
|
if e.kind() == std::io::ErrorKind::UnexpectedEof =>
|
||||||
|
{
|
||||||
|
info!("Playback finished (gen={})", generation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(symphonia::core::errors::Error::ResetRequired) => {
|
||||||
|
decoder.reset();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Packet error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if packet.track_id() != track_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update position from packet timestamp (ts is in codec timebase units = samples)
|
||||||
|
let pos_ms = (packet.ts() as f64 / sample_rate as f64 * 1000.0) as u64;
|
||||||
|
shared.position_ms.store(pos_ms, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let decoded = match decoder.decode(&packet) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(symphonia::core::errors::Error::DecodeError(e)) => {
|
||||||
|
warn!("Decode error: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Decode error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let spec = *decoded.spec();
|
||||||
|
let n_frames = decoded.frames();
|
||||||
|
let mut sample_buf = SampleBuffer::<f32>::new(n_frames as u64, spec);
|
||||||
|
sample_buf.copy_interleaved_ref(decoded);
|
||||||
|
|
||||||
|
if sample_tx.send(sample_buf.samples().to_vec()).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let audio buffer drain
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
// Only clear playing state if we're still the current generation
|
||||||
|
// (if generation changed, a new Play command has taken over — don't clobber its state)
|
||||||
|
if shared.generation.load(Ordering::SeqCst) == generation {
|
||||||
|
shared.playing.store(false, Ordering::SeqCst);
|
||||||
|
shared.paused.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1148
src/qconnect.rs
Normal file
1148
src/qconnect.rs
Normal file
File diff suppressed because it is too large
Load Diff
103
src/token.rs
Normal file
103
src/token.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
use crate::config::{Config, DeviceLinkCredentials};
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::types::OAuthTokens;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TokenManager {
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenManager {
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_device_link_credentials(&self, creds: DeviceLinkCredentials) -> Result<()> {
|
||||||
|
let path = self.config.device_link_credentials_path();
|
||||||
|
let content = serde_json::to_string_pretty(&creds)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_device_link_credentials(&self) -> Result<Option<DeviceLinkCredentials>> {
|
||||||
|
let path = self.config.device_link_credentials_path();
|
||||||
|
if path.exists() {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let creds: DeviceLinkCredentials = serde_json::from_str(&content)?;
|
||||||
|
if let Some(expires_at) = creds.expires_at {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
if expires_at > now {
|
||||||
|
return Ok(Some(creds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(creds))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_device_link_credentials(&self) -> Result<()> {
|
||||||
|
let path = self.config.device_link_credentials_path();
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn store_tokens(&self, tokens: &OAuthTokens) -> Result<()> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
let expires_at = now + tokens.expires_in as i64;
|
||||||
|
|
||||||
|
let creds = crate::config::StoredCredentials {
|
||||||
|
access_token: tokens.access_token.clone(),
|
||||||
|
refresh_token: tokens.refresh_token.clone(),
|
||||||
|
user_id: None,
|
||||||
|
expires_at: Some(expires_at),
|
||||||
|
email: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = self.config.clone();
|
||||||
|
config.store_credentials(creds)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_tokens(&self) -> Result<Option<OAuthTokens>> {
|
||||||
|
let config = crate::config::Config::load()?;
|
||||||
|
|
||||||
|
if let Some(creds) = config.credentials {
|
||||||
|
if let Some(expires_at) = creds.expires_at {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
if expires_at > now {
|
||||||
|
return Ok(Some(OAuthTokens {
|
||||||
|
token_type: "bearer".to_string(),
|
||||||
|
access_token: creds.access_token,
|
||||||
|
refresh_token: creds.refresh_token,
|
||||||
|
expires_in: (expires_at - now) as u64,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_token_expired(&self) -> Result<bool> {
|
||||||
|
if let Some(tokens) = self.load_tokens()? {
|
||||||
|
Ok(tokens.expires_in == 0)
|
||||||
|
} else {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
src/types.rs
Normal file
262
src/types.rs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthTokens {
|
||||||
|
pub token_type: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(rename = "publicId")]
|
||||||
|
pub public_id: Option<String>,
|
||||||
|
pub email: String,
|
||||||
|
pub login: String,
|
||||||
|
#[serde(rename = "firstname")]
|
||||||
|
pub first_name: Option<String>,
|
||||||
|
#[serde(rename = "lastname")]
|
||||||
|
pub last_name: Option<String>,
|
||||||
|
#[serde(rename = "display_name")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
#[serde(rename = "country_code")]
|
||||||
|
pub country_code: Option<String>,
|
||||||
|
#[serde(rename = "language_code")]
|
||||||
|
pub language_code: Option<String>,
|
||||||
|
pub zone: Option<String>,
|
||||||
|
pub store: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub subscription: Option<Subscription>,
|
||||||
|
pub credential: Option<Credential>,
|
||||||
|
#[serde(rename = "store_features")]
|
||||||
|
pub store_features: Option<StoreFeatures>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Subscription {
|
||||||
|
pub offer: String,
|
||||||
|
pub periodicity: String,
|
||||||
|
#[serde(rename = "start_date")]
|
||||||
|
pub start_date: Option<String>,
|
||||||
|
#[serde(rename = "end_date")]
|
||||||
|
pub end_date: Option<String>,
|
||||||
|
#[serde(rename = "is_canceled")]
|
||||||
|
pub is_canceled: bool,
|
||||||
|
#[serde(rename = "household_size_max")]
|
||||||
|
pub household_size_max: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Credential {
|
||||||
|
pub id: u64,
|
||||||
|
pub label: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub parameters: Option<CredentialParameters>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CredentialParameters {
|
||||||
|
#[serde(rename = "lossy_streaming")]
|
||||||
|
pub lossy_streaming: Option<bool>,
|
||||||
|
#[serde(rename = "lossless_streaming")]
|
||||||
|
pub lossless_streaming: Option<bool>,
|
||||||
|
#[serde(rename = "hires_streaming")]
|
||||||
|
pub hires_streaming: Option<bool>,
|
||||||
|
#[serde(rename = "hires_purchases_streaming")]
|
||||||
|
pub hires_purchases_streaming: Option<bool>,
|
||||||
|
#[serde(rename = "mobile_streaming")]
|
||||||
|
pub mobile_streaming: Option<bool>,
|
||||||
|
#[serde(rename = "offline_streaming")]
|
||||||
|
pub offline_streaming: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoreFeatures {
|
||||||
|
pub download: bool,
|
||||||
|
pub streaming: bool,
|
||||||
|
pub editorial: bool,
|
||||||
|
pub club: bool,
|
||||||
|
pub wallet: bool,
|
||||||
|
pub weeklyq: bool,
|
||||||
|
pub autoplay: bool,
|
||||||
|
#[serde(rename = "inapp_purchase_subscripton")]
|
||||||
|
pub inapp_purchase_subscription: bool,
|
||||||
|
pub opt_in: bool,
|
||||||
|
#[serde(rename = "pre_register_opt_in")]
|
||||||
|
pub pre_register_opt_in: bool,
|
||||||
|
#[serde(rename = "pre_register_zipcode")]
|
||||||
|
pub pre_register_zipcode: bool,
|
||||||
|
#[serde(rename = "music_import")]
|
||||||
|
pub music_import: bool,
|
||||||
|
pub radio: bool,
|
||||||
|
#[serde(rename = "stream_purchase")]
|
||||||
|
pub stream_purchase: bool,
|
||||||
|
pub lyrics: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub user: User,
|
||||||
|
#[serde(rename = "oauth2")]
|
||||||
|
pub oauth: OAuthTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LinkTokenRequest {
|
||||||
|
#[serde(rename = "link_action")]
|
||||||
|
pub link_action: String,
|
||||||
|
#[serde(rename = "external_device_id")]
|
||||||
|
pub external_device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LinkTokenResponse {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(rename = "link_token")]
|
||||||
|
pub link_token: Option<String>,
|
||||||
|
#[serde(rename = "link_device_id")]
|
||||||
|
pub link_device_id: Option<String>,
|
||||||
|
pub errors: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceTokenRequest {
|
||||||
|
#[serde(rename = "link_token")]
|
||||||
|
pub link_token: String,
|
||||||
|
#[serde(rename = "link_device_id")]
|
||||||
|
pub link_device_id: String,
|
||||||
|
#[serde(rename = "external_device_id")]
|
||||||
|
pub external_device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceTokenResponse {
|
||||||
|
pub status: String,
|
||||||
|
#[serde(rename = "oauth2")]
|
||||||
|
pub oauth: Option<OAuthTokens>,
|
||||||
|
#[serde(rename = "link_action")]
|
||||||
|
pub link_action: Option<String>,
|
||||||
|
pub errors: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QwsTokenResponse {
|
||||||
|
#[serde(rename = "jwt_qws")]
|
||||||
|
pub jwt_qws: QwsToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QwsToken {
|
||||||
|
pub exp: i64,
|
||||||
|
pub jwt: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub data: Option<T>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub code: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub code: Option<u32>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub errors: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
#[serde(rename = "track_count")]
|
||||||
|
pub track_count: Option<u32>,
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
pub image: Option<AlbumImage>,
|
||||||
|
pub artists: Option<Vec<Artist>>,
|
||||||
|
pub label: Option<Label>,
|
||||||
|
pub genre: Option<Genre>,
|
||||||
|
#[serde(rename = "audio_info")]
|
||||||
|
pub audio_info: Option<AudioInfo>,
|
||||||
|
pub rights: Option<Rights>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AlbumImage {
|
||||||
|
pub small: Option<String>,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub large: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Artist {
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub roles: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Label {
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Genre {
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub path: Option<Vec<u64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AudioInfo {
|
||||||
|
#[serde(rename = "maximum_sampling_rate")]
|
||||||
|
pub maximum_sampling_rate: Option<f64>,
|
||||||
|
#[serde(rename = "maximum_bit_depth")]
|
||||||
|
pub maximum_bit_depth: Option<u32>,
|
||||||
|
#[serde(rename = "maximum_channel_count")]
|
||||||
|
pub maximum_channel_count: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Rights {
|
||||||
|
pub purchasable: Option<bool>,
|
||||||
|
pub streamable: Option<bool>,
|
||||||
|
pub downloadable: Option<bool>,
|
||||||
|
#[serde(rename = "hires_streamable")]
|
||||||
|
pub hires_streamable: Option<bool>,
|
||||||
|
#[serde(rename = "hires_purchasable")]
|
||||||
|
pub hires_purchasable: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Track {
|
||||||
|
pub id: u64,
|
||||||
|
pub title: String,
|
||||||
|
pub performer: Option<Artist>,
|
||||||
|
pub duration: Option<u32>,
|
||||||
|
pub track_number: Option<u32>,
|
||||||
|
pub disc_number: Option<u32>,
|
||||||
|
pub artists: Option<Vec<Artist>>,
|
||||||
|
pub album: Option<Album>,
|
||||||
|
#[serde(rename = "audio_info")]
|
||||||
|
pub audio_info: Option<AudioInfo>,
|
||||||
|
pub rights: Option<Rights>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub id: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tracks_count: Option<u32>,
|
||||||
|
pub image: Option<AlbumImage>,
|
||||||
|
pub user: Option<User>,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user