mirror of
https://gitlab.freedesktop.org/pulseaudio/pulseaudio.git
synced 2026-02-12 04:27:50 -05:00
raop: Add support for newest Airplay2 devices
Changed rtsp_client to support response with body Support timing packet requests earlier (required by newest AirTunes) Send progress with SET_PARAMETER (required by newest AirTunes)
This commit is contained in:
parent
b961f83002
commit
96cbee1230
4 changed files with 215 additions and 111 deletions
|
|
@ -112,6 +112,7 @@ struct pa_raop_client {
|
|||
int udp_sfd;
|
||||
int udp_cfd;
|
||||
int udp_tfd;
|
||||
int udp_tport;
|
||||
|
||||
pa_raop_packet_buffer *pbuf;
|
||||
|
||||
|
|
@ -884,7 +885,7 @@ static void rtsp_stream_cb(pa_rtsp_client *rtsp, pa_rtsp_state_t state, pa_rtsp_
|
|||
char *url;
|
||||
int ipv;
|
||||
|
||||
pa_log_debug("RAOP: CONNECTED");
|
||||
pa_log_debug("RAOP: CONNECT");
|
||||
|
||||
ip = pa_rtsp_localip(c->rtsp);
|
||||
if (pa_is_ip6_address(ip)) {
|
||||
|
|
@ -966,28 +967,35 @@ connect_finish:
|
|||
break;
|
||||
}
|
||||
|
||||
case STATE_ANNOUNCE: {
|
||||
uint16_t cport = DEFAULT_UDP_CONTROL_PORT;
|
||||
uint16_t tport = DEFAULT_UDP_TIMING_PORT;
|
||||
case STATE_ANNOUNCE:
|
||||
case STATE_AUTH_SETUP: {
|
||||
char *trs = NULL;
|
||||
|
||||
pa_log_debug("RAOP: ANNOUNCE");
|
||||
uint16_t cport = DEFAULT_UDP_CONTROL_PORT;
|
||||
uint16_t tport = DEFAULT_UDP_TIMING_PORT;
|
||||
|
||||
pa_log_debug("RAOP: ANNOUNCE or AUTH-SETUP");
|
||||
|
||||
if (c->protocol == PA_RAOP_PROTOCOL_TCP) {
|
||||
trs = pa_sprintf_malloc(
|
||||
"RTP/AVP/TCP;unicast;interleaved=0-1;mode=record");
|
||||
} else if (c->protocol == PA_RAOP_PROTOCOL_UDP) {
|
||||
c->udp_cfd = open_bind_udp_socket(c, &cport);
|
||||
c->udp_tfd = open_bind_udp_socket(c, &tport);
|
||||
if (c->udp_cfd < 0 || c->udp_tfd < 0)
|
||||
goto annonce_error;
|
||||
|
||||
if (state == STATE_ANNOUNCE) {
|
||||
c->udp_cfd = open_bind_udp_socket(c, &cport);
|
||||
c->udp_tfd = open_bind_udp_socket(c, &tport);
|
||||
if (c->udp_cfd < 0 || c->udp_tfd < 0)
|
||||
goto annonce_error;
|
||||
}
|
||||
trs = pa_sprintf_malloc(
|
||||
"RTP/AVP/UDP;unicast;interleaved=0-1;mode=record;"
|
||||
"control_port=%d;timing_port=%d",
|
||||
cport, tport);
|
||||
}
|
||||
|
||||
if (state == STATE_ANNOUNCE)
|
||||
if (c->state_callback)
|
||||
c->state_callback(PA_RAOP_CONNECTED, c->state_userdata);
|
||||
|
||||
pa_rtsp_setup(c->rtsp, trs);
|
||||
|
||||
pa_xfree(trs);
|
||||
|
|
@ -1011,128 +1019,153 @@ connect_finish:
|
|||
}
|
||||
|
||||
case STATE_SETUP: {
|
||||
pa_socket_client *sc = NULL;
|
||||
uint32_t sport = DEFAULT_UDP_AUDIO_PORT;
|
||||
uint32_t cport =0, tport = 0;
|
||||
char *ajs, *token, *pc, *trs;
|
||||
const char *token_state = NULL;
|
||||
char delimiters[] = ";";
|
||||
if (status == STATUS_FORBIDDEN) {
|
||||
struct {
|
||||
uint32_t ci1;
|
||||
uint32_t ci2;
|
||||
} rci;
|
||||
pa_log_debug("RAOP: SETUP - FORBIDDEN");
|
||||
|
||||
pa_log_debug("RAOP: SETUP");
|
||||
pa_random(&rci, sizeof(rci));
|
||||
c->sci = pa_sprintf_malloc("%08x%08x", rci.ci1, rci.ci2);
|
||||
pa_rtsp_add_header(c->rtsp, "Client-Instance", c->sci);
|
||||
|
||||
ajs = pa_xstrdup(pa_headerlist_gets(headers, "Audio-Jack-Status"));
|
||||
|
||||
if (ajs) {
|
||||
c->jack_type = JACK_TYPE_ANALOG;
|
||||
c->jack_status = JACK_STATUS_DISCONNECTED;
|
||||
|
||||
while ((token = pa_split(ajs, delimiters, &token_state))) {
|
||||
if ((pc = strstr(token, "="))) {
|
||||
*pc = 0;
|
||||
if (pa_streq(token, "type") && pa_streq(pc + 1, "digital"))
|
||||
c->jack_type = JACK_TYPE_DIGITAL;
|
||||
} else {
|
||||
if (pa_streq(token, "connected"))
|
||||
c->jack_status = JACK_STATUS_CONNECTED;
|
||||
}
|
||||
pa_xfree(token);
|
||||
// Airplay 2 devices require additional authentication step
|
||||
// It is actually useful to authenticate AirTunes server, not the device (see pa_rtsp_auth_setup for more details)
|
||||
if (pa_rtsp_auth_setup(
|
||||
c->rtsp,
|
||||
"\x01\x4e\xea\xd0\x4e\xa9\x2e\x47\x69\xd2\xe1\xfb\xd0\x96\x81\xd5\x94\xa8\xef\x18\x45\x4a\x24\xae\xaf\xb3\x14\x97\x0d\xa0\xb5\xa3\x49"
|
||||
) < 0) {
|
||||
pa_log_error("RAOP: Unable to POST auth-setup");
|
||||
}
|
||||
|
||||
} else {
|
||||
pa_log_warn("\"Audio-Jack-Status\" missing in RTSP setup response");
|
||||
break;
|
||||
}
|
||||
|
||||
sport = pa_rtsp_serverport(c->rtsp);
|
||||
if (sport <= 0)
|
||||
goto setup_error;
|
||||
if (status == STATUS_OK) {
|
||||
pa_socket_client *sc = NULL;
|
||||
uint32_t sport = DEFAULT_UDP_AUDIO_PORT;
|
||||
uint32_t cport = 0, tport = 0;
|
||||
char *ajs, *token, *pc, *trs;
|
||||
const char *token_state = NULL;
|
||||
char delimiters[] = ";";
|
||||
|
||||
token_state = NULL;
|
||||
if (c->protocol == PA_RAOP_PROTOCOL_TCP) {
|
||||
if (!(sc = pa_socket_client_new_string(c->core->mainloop, true, c->host, sport)))
|
||||
goto setup_error;
|
||||
pa_log_debug("RAOP: SETUP");
|
||||
|
||||
pa_socket_client_ref(sc);
|
||||
pa_socket_client_set_callback(sc, tcp_connection_cb, c);
|
||||
ajs = pa_xstrdup(pa_headerlist_gets(headers, "Audio-Jack-Status"));
|
||||
|
||||
pa_socket_client_unref(sc);
|
||||
sc = NULL;
|
||||
} else if (c->protocol == PA_RAOP_PROTOCOL_UDP) {
|
||||
trs = pa_xstrdup(pa_headerlist_gets(headers, "Transport"));
|
||||
if (ajs) {
|
||||
c->jack_type = JACK_TYPE_ANALOG;
|
||||
c->jack_status = JACK_STATUS_DISCONNECTED;
|
||||
|
||||
if (trs) {
|
||||
/* Now parse out the server port component of the response. */
|
||||
while ((token = pa_split(trs, delimiters, &token_state))) {
|
||||
while ((token = pa_split(ajs, delimiters, &token_state))) {
|
||||
if ((pc = strstr(token, "="))) {
|
||||
*pc = 0;
|
||||
if (pa_streq(token, "control_port")) {
|
||||
if (pa_atou(pc + 1, &cport) < 0)
|
||||
goto setup_error_parse;
|
||||
}
|
||||
if (pa_streq(token, "timing_port")) {
|
||||
if (pa_atou(pc + 1, &tport) < 0)
|
||||
goto setup_error_parse;
|
||||
}
|
||||
*pc = '=';
|
||||
if (pa_streq(token, "type") && pa_streq(pc + 1, "digital"))
|
||||
c->jack_type = JACK_TYPE_DIGITAL;
|
||||
} else {
|
||||
if (pa_streq(token, "connected"))
|
||||
c->jack_status = JACK_STATUS_CONNECTED;
|
||||
}
|
||||
pa_xfree(token);
|
||||
}
|
||||
pa_xfree(trs);
|
||||
|
||||
} else {
|
||||
pa_log_warn("\"Transport\" missing in RTSP setup response");
|
||||
pa_log_warn("\"Audio-Jack-Status\" missing in RTSP setup response");
|
||||
}
|
||||
|
||||
if (cport <= 0 || tport <= 0)
|
||||
sport = pa_rtsp_serverport(c->rtsp);
|
||||
if (sport <= 0)
|
||||
goto setup_error;
|
||||
|
||||
if ((c->udp_sfd = connect_udp_socket(c, -1, sport)) <= 0)
|
||||
goto setup_error;
|
||||
if ((c->udp_cfd = connect_udp_socket(c, c->udp_cfd, cport)) <= 0)
|
||||
goto setup_error;
|
||||
if ((c->udp_tfd = connect_udp_socket(c, c->udp_tfd, tport)) <= 0)
|
||||
goto setup_error;
|
||||
token_state = NULL;
|
||||
if (c->protocol == PA_RAOP_PROTOCOL_TCP) {
|
||||
if (!(sc = pa_socket_client_new_string(c->core->mainloop, true, c->host, sport)))
|
||||
goto setup_error;
|
||||
|
||||
pa_log_debug("Connection established (UDP;control_port=%d;timing_port=%d)", cport, tport);
|
||||
pa_socket_client_ref(sc);
|
||||
pa_socket_client_set_callback(sc, tcp_connection_cb, c);
|
||||
|
||||
/* Send an initial UDP packet so a connection tracking firewall
|
||||
* knows the src_ip:src_port <-> dest_ip:dest_port relation
|
||||
* and accepts the incoming timing packets.
|
||||
*/
|
||||
send_initial_udp_timing_packet(c);
|
||||
pa_log_debug("Sent initial timing packet to UDP port %d", tport);
|
||||
pa_socket_client_unref(sc);
|
||||
sc = NULL;
|
||||
} else if (c->protocol == PA_RAOP_PROTOCOL_UDP) {
|
||||
trs = pa_xstrdup(pa_headerlist_gets(headers, "Transport"));
|
||||
|
||||
if (trs) {
|
||||
/* Now parse out the server port component of the response. */
|
||||
while ((token = pa_split(trs, delimiters, &token_state))) {
|
||||
if ((pc = strstr(token, "="))) {
|
||||
*pc = 0;
|
||||
if (pa_streq(token, "control_port")) {
|
||||
if (pa_atou(pc + 1, &cport) < 0)
|
||||
goto setup_error_parse;
|
||||
}
|
||||
if (pa_streq(token, "timing_port")) {
|
||||
if (pa_atou(pc + 1, &tport) < 0)
|
||||
goto setup_error_parse;
|
||||
}
|
||||
*pc = '=';
|
||||
}
|
||||
pa_xfree(token);
|
||||
}
|
||||
pa_xfree(trs);
|
||||
} else {
|
||||
pa_log_warn("\"Transport\" missing in RTSP setup response");
|
||||
}
|
||||
|
||||
if (cport <= 0 || tport <= 0)
|
||||
goto setup_error;
|
||||
|
||||
/* Send an initial UDP packet so a connection tracking firewall
|
||||
* knows the src_ip:src_port <-> dest_ip:dest_port relation
|
||||
* and accepts the incoming timing packets.
|
||||
*/
|
||||
send_initial_udp_timing_packet(c);
|
||||
pa_log_debug("Sent initial timing packet to UDP port %d", tport);
|
||||
|
||||
if ((c->udp_sfd = connect_udp_socket(c, -1, sport)) <= 0)
|
||||
goto setup_error;
|
||||
if ((c->udp_cfd = connect_udp_socket(c, c->udp_cfd, cport)) <= 0)
|
||||
goto setup_error;
|
||||
if ((c->udp_tfd = connect_udp_socket(c, c->udp_tfd, tport)) <= 0)
|
||||
goto setup_error;
|
||||
|
||||
pa_log_debug("Connection established (UDP;control_port=%d;timing_port=%d)", cport, tport);
|
||||
|
||||
}
|
||||
|
||||
pa_rtsp_record(c->rtsp, &c->seq, &c->rtptime);
|
||||
|
||||
pa_xfree(ajs);
|
||||
break;
|
||||
|
||||
|
||||
setup_error_parse:
|
||||
pa_log("Failed parsing server port components");
|
||||
pa_xfree(token);
|
||||
pa_xfree(trs);
|
||||
/* fall-thru */
|
||||
setup_error:
|
||||
if (c->tcp_sfd >= 0)
|
||||
pa_close(c->tcp_sfd);
|
||||
c->tcp_sfd = -1;
|
||||
|
||||
if (c->udp_sfd >= 0)
|
||||
pa_close(c->udp_sfd);
|
||||
c->udp_sfd = -1;
|
||||
|
||||
c->udp_cfd = c->udp_tfd = -1;
|
||||
|
||||
pa_rtsp_client_free(c->rtsp);
|
||||
|
||||
pa_log_error("aborting RTSP setup, failed creating required sockets");
|
||||
|
||||
if (c->state_callback)
|
||||
c->state_callback(PA_RAOP_CONNECTED, c->state_userdata);
|
||||
c->state_callback(PA_RAOP_DISCONNECTED, c->state_userdata);
|
||||
|
||||
c->rtsp = NULL;
|
||||
break;
|
||||
}
|
||||
|
||||
pa_rtsp_record(c->rtsp, &c->seq, &c->rtptime);
|
||||
|
||||
pa_xfree(ajs);
|
||||
break;
|
||||
|
||||
setup_error_parse:
|
||||
pa_log("Failed parsing server port components");
|
||||
pa_xfree(token);
|
||||
pa_xfree(trs);
|
||||
/* fall-thru */
|
||||
setup_error:
|
||||
if (c->tcp_sfd >= 0)
|
||||
pa_close(c->tcp_sfd);
|
||||
c->tcp_sfd = -1;
|
||||
|
||||
if (c->udp_sfd >= 0)
|
||||
pa_close(c->udp_sfd);
|
||||
c->udp_sfd = -1;
|
||||
|
||||
c->udp_cfd = c->udp_tfd = -1;
|
||||
|
||||
pa_rtsp_client_free(c->rtsp);
|
||||
|
||||
pa_log_error("aborting RTSP setup, failed creating required sockets");
|
||||
|
||||
if (c->state_callback)
|
||||
c->state_callback(PA_RAOP_DISCONNECTED, c->state_userdata);
|
||||
|
||||
c->rtsp = NULL;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1188,7 +1221,7 @@ connect_finish:
|
|||
c->udp_sfd = -1;
|
||||
|
||||
/* Polling sockets will be closed by sink */
|
||||
c->udp_cfd = c->udp_tfd = -1;
|
||||
c->udp_cfd = c->udp_tfd = c->udp_tport = -1;
|
||||
c->tcp_sfd = -1;
|
||||
|
||||
pa_rtsp_client_free(c->rtsp);
|
||||
|
|
@ -1216,7 +1249,7 @@ connect_finish:
|
|||
c->udp_sfd = -1;
|
||||
|
||||
/* Polling sockets will be closed by sink */
|
||||
c->udp_cfd = c->udp_tfd = -1;
|
||||
c->udp_cfd = c->udp_tfd = c->udp_tport = -1;
|
||||
c->tcp_sfd = -1;
|
||||
|
||||
pa_log_error("RTSP control channel closed (disconnected)");
|
||||
|
|
@ -1474,6 +1507,7 @@ pa_raop_client* pa_raop_client_new(pa_core *core, const char *host, pa_raop_prot
|
|||
c->udp_sfd = -1;
|
||||
c->udp_cfd = -1;
|
||||
c->udp_tfd = -1;
|
||||
c->udp_tport = -1;
|
||||
|
||||
c->secret = NULL;
|
||||
if (c->encryption != PA_RAOP_ENCRYPTION_NONE)
|
||||
|
|
@ -1856,3 +1890,28 @@ void pa_raop_client_set_state_callback(pa_raop_client *c, pa_raop_client_state_c
|
|||
c->state_callback = callback;
|
||||
c->state_userdata = userdata;
|
||||
}
|
||||
|
||||
void pa_raop_client_set_tport(pa_raop_client *c, int udp_tport) {
|
||||
pa_assert(c);
|
||||
|
||||
if (c->udp_tport < 0) {
|
||||
c->udp_tport = udp_tport;
|
||||
if ((c->udp_tfd = connect_udp_socket(c, c->udp_tfd, udp_tport)) <= 0) {
|
||||
pa_log_error("RAOP: Error while connecting the UDP timing port");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void pa_raop_client_send_progress (pa_raop_client *c){
|
||||
char *param;
|
||||
|
||||
pa_assert(c);
|
||||
|
||||
param = pa_sprintf_malloc("progress: %s/%s/%s\r\n", "0","0","0");
|
||||
/* We just hit and hope, cannot wait for the callback. */
|
||||
if (c->rtsp != NULL && pa_rtsp_exec_ready(c->rtsp))
|
||||
pa_rtsp_setparameter(c->rtsp, param);
|
||||
|
||||
pa_xfree(param);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,5 +82,7 @@ ssize_t pa_raop_client_send_audio_packet(pa_raop_client *c, pa_memchunk *block,
|
|||
|
||||
typedef void (*pa_raop_client_state_cb_t)(pa_raop_state_t state, void *userdata);
|
||||
void pa_raop_client_set_state_callback(pa_raop_client *c, pa_raop_client_state_cb_t callback, void *userdata);
|
||||
void pa_raop_client_set_tport(pa_raop_client *c, int udp_tport);
|
||||
void pa_raop_client_send_progress (pa_raop_client *c);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ struct pa_rtsp_client {
|
|||
char *last_header;
|
||||
pa_strbuf *header_buffer;
|
||||
pa_headerlist* response_headers;
|
||||
int content_length;
|
||||
|
||||
char *localip;
|
||||
char *url;
|
||||
|
|
@ -146,7 +147,7 @@ static void headers_read(pa_rtsp_client *c) {
|
|||
pa_assert(c->callback);
|
||||
|
||||
/* Deal with a SETUP response */
|
||||
if (STATE_SETUP == c->state) {
|
||||
if (STATE_SETUP == c->state && c->status == STATUS_OK) {
|
||||
const char* token_state = NULL;
|
||||
const char* pc = NULL;
|
||||
c->session = pa_xstrdup(pa_headerlist_gets(c->response_headers, "Session"));
|
||||
|
|
@ -187,6 +188,12 @@ static void headers_read(pa_rtsp_client *c) {
|
|||
c->callback(c, c->state, c->status, c->response_headers, c->userdata);
|
||||
}
|
||||
|
||||
static void stream_callback(pa_ioline *line, void *userdata) {
|
||||
pa_rtsp_client *c = userdata;
|
||||
|
||||
headers_read(c);
|
||||
}
|
||||
|
||||
static void line_callback(pa_ioline *line, const char *s, void *userdata) {
|
||||
pa_rtsp_client *c = userdata;
|
||||
char *delimpos;
|
||||
|
|
@ -218,6 +225,7 @@ static void line_callback(pa_ioline *line, const char *s, void *userdata) {
|
|||
|
||||
c->status = STATUS_OK;
|
||||
c->waiting = 0;
|
||||
c->content_length = 0;
|
||||
goto exit;
|
||||
} else if (c->waiting && pa_streq(s2, "RTSP/1.0 401 Unauthorized")) {
|
||||
if (c->response_headers)
|
||||
|
|
@ -227,6 +235,14 @@ static void line_callback(pa_ioline *line, const char *s, void *userdata) {
|
|||
c->status = STATUS_UNAUTHORIZED;
|
||||
c->waiting = 0;
|
||||
goto exit;
|
||||
} else if (c->waiting && pa_streq(s2, "RTSP/1.0 403 Forbidden")) {
|
||||
if (c->response_headers)
|
||||
pa_headerlist_free(c->response_headers);
|
||||
c->response_headers = pa_headerlist_new();
|
||||
|
||||
c->status = STATUS_FORBIDDEN;
|
||||
c->waiting = 0;
|
||||
goto exit;
|
||||
} else if (c->waiting) {
|
||||
pa_log_warn("Unexpected/Unhandled response: %s", s2);
|
||||
|
||||
|
|
@ -253,7 +269,13 @@ static void line_callback(pa_ioline *line, const char *s, void *userdata) {
|
|||
}
|
||||
|
||||
pa_log_debug("Full response received. Dispatching");
|
||||
headers_read(c);
|
||||
|
||||
if (c->content_length!=0) {
|
||||
pa_ioline_set_streamcallback(line, stream_callback, c->content_length, c);
|
||||
} else {
|
||||
headers_read(c);
|
||||
}
|
||||
|
||||
goto exit;
|
||||
}
|
||||
|
||||
|
|
@ -274,6 +296,8 @@ static void line_callback(pa_ioline *line, const char *s, void *userdata) {
|
|||
/* This is not a continuation header so let's dump the full
|
||||
header/value into our proplist */
|
||||
pa_headerlist_puts(c->response_headers, c->last_header, tmp);
|
||||
if (pa_headerlist_gets(c->response_headers, "Content-Length"))
|
||||
pa_atoi(pa_headerlist_gets(c->response_headers, "Content-Length"), &c->content_length);
|
||||
pa_xfree(tmp);
|
||||
pa_xfree(c->last_header);
|
||||
c->last_header = NULL;
|
||||
|
|
@ -537,6 +561,22 @@ int pa_rtsp_options(pa_rtsp_client *c) {
|
|||
return rv;
|
||||
}
|
||||
|
||||
int pa_rtsp_auth_setup(pa_rtsp_client *c, const char* key) {
|
||||
char *url;
|
||||
int rv;
|
||||
|
||||
pa_assert(c);
|
||||
|
||||
url = c->url;
|
||||
c->state = STATE_AUTH_SETUP;
|
||||
|
||||
c->url = (char *)"/auth-setup";
|
||||
rv = rtsp_exec(c, "POST", "application/octet-stream", key, 1, NULL);
|
||||
|
||||
c->url = url;
|
||||
return rv;
|
||||
}
|
||||
|
||||
int pa_rtsp_announce(pa_rtsp_client *c, const char *sdp) {
|
||||
int rv;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ typedef enum pa_rtsp_state {
|
|||
STATE_OPTIONS,
|
||||
STATE_ANNOUNCE,
|
||||
STATE_SETUP,
|
||||
STATE_AUTH_SETUP,
|
||||
STATE_RECORD,
|
||||
STATE_SET_PARAMETER,
|
||||
STATE_FLUSH,
|
||||
|
|
@ -48,6 +49,7 @@ typedef enum pa_rtsp_status {
|
|||
STATUS_OK = 200,
|
||||
STATUS_BAD_REQUEST = 400,
|
||||
STATUS_UNAUTHORIZED = 401,
|
||||
STATUS_FORBIDDEN = 403,
|
||||
STATUS_NO_RESPONSE = 444,
|
||||
STATUS_INTERNAL_ERROR = 500
|
||||
} pa_rtsp_status_t;
|
||||
|
|
@ -79,5 +81,6 @@ int pa_rtsp_record(pa_rtsp_client *c, uint16_t *seq, uint32_t *rtptime);
|
|||
int pa_rtsp_setparameter(pa_rtsp_client *c, const char *param);
|
||||
int pa_rtsp_flush(pa_rtsp_client *c, uint16_t seq, uint32_t rtptime);
|
||||
int pa_rtsp_teardown(pa_rtsp_client *c);
|
||||
int pa_rtsp_auth_setup(pa_rtsp_client *c, const char* auth_key);
|
||||
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue