diff --git a/src/Makefile.am b/src/Makefile.am index 05ace749..10afdffa 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -140,6 +140,7 @@ owntone_SOURCES = main.c \ outputs.h outputs.c \ outputs/rtp_common.h outputs/rtp_common.c \ outputs/raop.c outputs/airplay.c $(PAIR_AP_SRC) \ + outputs/airplay_events.c outputs/airplay_events.h \ outputs/streaming.c outputs/dummy.c outputs/fifo.c \ $(ALSA_SRC) $(PULSEAUDIO_SRC) $(CHROMECAST_SRC) \ evrtsp/rtsp.c evrtsp/evrtsp.h evrtsp/rtsp-internal.h evrtsp/log.h \ diff --git a/src/outputs/airplay.c b/src/outputs/airplay.c index 3b5f8896..c9ac7e22 100644 --- a/src/outputs/airplay.c +++ b/src/outputs/airplay.c @@ -54,6 +54,7 @@ #include "transcode.h" #include "outputs.h" +#include "airplay_events.h" #include "pair_ap/pair.h" /* List of TODO's for AirPlay 2 @@ -84,6 +85,10 @@ #define AIRPLAY_RTP_PAYLOADTYPE 0x60 +// For transient pairing the key_len will be 64 bytes, but only 32 are used for +// audio payload encryption. For normal pairing the key is 32 bytes. +#define AIRPLAY_AUDIO_KEY_LEN 32 + // How many RTP packets keep in a buffer for retransmission #define AIRPLAY_PACKET_BUFFER_SIZE 1000 @@ -269,18 +274,16 @@ struct airplay_session /* Pairing, see pair.h */ enum pair_type pair_type; struct pair_cipher_context *control_cipher_ctx; - struct pair_cipher_context *events_cipher_ctx; struct pair_verify_context *pair_verify_ctx; struct pair_setup_context *pair_setup_ctx; - uint8_t shared_secret[32]; + uint8_t shared_secret[64]; + size_t shared_secret_len; // 32 or 64, see AIRPLAY_AUDIO_KEY_LEN for comment + gcry_cipher_hd_t packet_cipher_hd; int server_fd; - int events_fd; - struct event *eventsev; - struct airplay_service *timing_svc; struct airplay_service *control_svc; @@ -1210,21 +1213,14 @@ session_free(struct airplay_session *rs) if (rs->deferredev) event_free(rs->deferredev); - if (rs->eventsev) - event_free(rs->eventsev); - if (rs->server_fd >= 0) close(rs->server_fd); - if (rs->events_fd >= 0) - close(rs->events_fd); - chacha_close(rs->packet_cipher_hd); pair_setup_free(rs->pair_setup_ctx); pair_verify_free(rs->pair_verify_ctx); pair_cipher_free(rs->control_cipher_ctx); - pair_cipher_free(rs->events_cipher_ctx); free(rs->local_address); free(rs->realm); @@ -1413,15 +1409,18 @@ static int session_cipher_setup(struct airplay_session *rs, const uint8_t *key, size_t key_len) { struct pair_cipher_context *control_cipher_ctx = NULL; - struct pair_cipher_context *events_cipher_ctx = NULL; gcry_cipher_hd_t packet_cipher_hd = NULL; - if (key_len < sizeof(rs->shared_secret)) // For transient pairing the key_len will be 64 bytes, and rs->shared_secret is 32 bytes + // For transient pairing the key_len will be 64 bytes, and rs->shared_secret is 32 bytes + if (key_len < AIRPLAY_AUDIO_KEY_LEN || key_len > sizeof(rs->shared_secret)) { DPRINTF(E_LOG, L_AIRPLAY, "Ciphering setup error: Unexpected key length (%zu)\n", key_len); goto error; } + rs->shared_secret_len = key_len; + memcpy(rs->shared_secret, key, key_len); + control_cipher_ctx = pair_cipher_new(rs->pair_type, 0, key, key_len); if (!control_cipher_ctx) { @@ -1429,17 +1428,7 @@ session_cipher_setup(struct airplay_session *rs, const uint8_t *key, size_t key_ goto error; } - events_cipher_ctx = pair_cipher_new(rs->pair_type, 1, key, key_len); - if (!events_cipher_ctx) - { - DPRINTF(E_LOG, L_AIRPLAY, "Could not create events ciphering context\n"); - goto error; - } - - // Copy the first 32 bytes, will be used for encrypting audio payload - memcpy(rs->shared_secret, key, sizeof(rs->shared_secret)); - - packet_cipher_hd = chacha_open(rs->shared_secret, sizeof(rs->shared_secret)); + packet_cipher_hd = chacha_open(rs->shared_secret, AIRPLAY_AUDIO_KEY_LEN); if (!packet_cipher_hd) { DPRINTF(E_LOG, L_AIRPLAY, "Could not create packet ciphering handle\n"); @@ -1450,7 +1439,6 @@ session_cipher_setup(struct airplay_session *rs, const uint8_t *key, size_t key_ rs->state = AIRPLAY_STATE_ENCRYPTED; rs->control_cipher_ctx = control_cipher_ctx; - rs->events_cipher_ctx = events_cipher_ctx; rs->packet_cipher_hd = packet_cipher_hd; evrtsp_connection_set_ciphercb(rs->ctrl, rtsp_cipher, rs); @@ -1459,7 +1447,6 @@ session_cipher_setup(struct airplay_session *rs, const uint8_t *key, size_t key_ error: pair_cipher_free(control_cipher_ctx); - pair_cipher_free(events_cipher_ctx); chacha_close(packet_cipher_hd); return -1; } @@ -1566,7 +1553,6 @@ session_make(struct output_device *rd, int callback_id) rs->callback_id = callback_id; rs->server_fd = -1; - rs->events_fd = -1; rs->password = rd->password; @@ -2310,42 +2296,6 @@ control_svc_cb(int fd, short what, void *arg) } -/* ----------------------------- Event receiver ------------------------------*/ - -// TODO actually handle events... -static void -event_channel_cb(int fd, short what, void *arg) -{ - struct airplay_session *rs = arg; - ssize_t in_len; - int ret; - uint8_t in[4096]; //TODO - uint8_t *out; - size_t out_len = 0; - - in_len = recv(fd, in, sizeof(in), 0); - if (in_len < 0) - DPRINTF(E_WARN, L_AIRPLAY, "Event channel to '%s' returned an error: %s\n", rs->devname, strerror(errno)); - - if (in_len <= 0) - return; - - DPRINTF(E_DBG, L_AIRPLAY, "Received an event from '%s' (len=%zd)\n", rs->devname, in_len); - - if (in_len == sizeof(in)) - return; // Longer than expected, give up - - ret = pair_decrypt(&out, &out_len, in, in_len, rs->events_cipher_ctx); - if (ret < 0) - { - DPRINTF(E_DBG, L_AIRPLAY, "Error decrypting event from '%s', error was: %s\n", rs->devname, pair_cipher_errmsg(rs->events_cipher_ctx)); - return; - } - - DHEXDUMP(E_DBG, L_AIRPLAY, out, out_len, "Decrypted incoming event\n"); -} - - /* -------------------- Handlers for sending RTSP requests ------------------ */ static int @@ -2552,7 +2502,7 @@ payload_make_setup_stream(struct evrtsp_request *req, struct airplay_session *rs wplist_dict_add_bool(stream, "isMedia", true); // ? wplist_dict_add_uint(stream, "latencyMax", 88200); // TODO how do these latencys work? wplist_dict_add_uint(stream, "latencyMin", 11025); - wplist_dict_add_data(stream, "shk", rs->shared_secret, sizeof(rs->shared_secret)); + wplist_dict_add_data(stream, "shk", rs->shared_secret, AIRPLAY_AUDIO_KEY_LEN); wplist_dict_add_uint(stream, "spf", AIRPLAY_SAMPLES_PER_PACKET); // frames per packet wplist_dict_add_uint(stream, "sr", AIRPLAY_QUALITY_SAMPLE_RATE_DEFAULT); // sample rate wplist_dict_add_uint(stream, "type", AIRPLAY_RTP_PAYLOADTYPE); // RTP type, 0x60 = 96 real time, 103 buffered @@ -2963,16 +2913,11 @@ response_handler_setup_stream(struct evrtsp_request *req, struct airplay_session } // Reverse connection, used to receive playback events from device - rs->events_fd = net_connect(rs->address, rs->events_port, SOCK_STREAM, "AirPlay events"); - if (rs->events_fd < 0) + ret = airplay_events_listen(rs->devname, rs->address, rs->events_port, rs->shared_secret, rs->shared_secret_len); + if (ret < 0) { DPRINTF(E_WARN, L_AIRPLAY, "Could not connect to '%s' events port %u, proceeding anyway\n", rs->devname, rs->events_port); } - else - { - rs->eventsev = event_new(evbase_player, rs->events_fd, EV_READ | EV_PERSIST, event_channel_cb, rs); - event_add(rs->eventsev, NULL); - } rs->state = AIRPLAY_STATE_SETUP; @@ -3252,12 +3197,6 @@ response_handler_pair_setup2(struct evrtsp_request *req, struct airplay_session goto error; } - if (shared_secret_len < sizeof(rs->shared_secret)) // We expect 64 bytes, and rs->shared_secret is 32 bytes - { - DPRINTF(E_LOG, L_AIRPLAY, "Transient setup result error: Unexpected key length (%zu)\n", shared_secret_len); - goto error; - } - ret = session_cipher_setup(rs, shared_secret, shared_secret_len); if (ret < 0) { @@ -3354,12 +3293,6 @@ response_handler_pair_verify2(struct evrtsp_request *req, struct airplay_session goto error; } - if (sizeof(rs->shared_secret) != shared_secret_len) - { - DPRINTF(E_LOG, L_AIRPLAY, "Pair verify result error: Unexpected key length (%zu)\n", shared_secret_len); - goto error; - } - ret = session_cipher_setup(rs, shared_secret, shared_secret_len); if (ret < 0) { @@ -4081,15 +4014,24 @@ airplay_init(void) goto out_stop_timing; } + ret = airplay_events_init(); + if (ret < 0) + { + DPRINTF(E_LOG, L_AIRPLAY, "AirPlay events failed to start\n"); + goto out_stop_control; + } + ret = mdns_browse("_airplay._tcp", airplay_device_cb, MDNS_CONNECTION_TEST); if (ret < 0) { DPRINTF(E_LOG, L_AIRPLAY, "Could not add mDNS browser for AirPlay devices\n"); - goto out_stop_control; + goto out_stop_events; } return 0; + out_stop_events: + airplay_events_deinit(); out_stop_control: service_stop(&airplay_control_svc); out_stop_timing: @@ -4105,6 +4047,7 @@ airplay_deinit(void) { struct airplay_session *rs; + airplay_events_deinit(); service_stop(&airplay_control_svc); service_stop(&airplay_timing_svc); diff --git a/src/outputs/airplay_events.c b/src/outputs/airplay_events.c new file mode 100644 index 00000000..27204b4e --- /dev/null +++ b/src/outputs/airplay_events.c @@ -0,0 +1,551 @@ +/* + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ + +#ifdef HAVE_CONFIG_H +# include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "airplay_events.h" +#include "commands.h" +#include "misc.h" +#include "logger.h" +#include "player.h" +#include "pair_ap/pair.h" + +#define RTSP_VERSION "RTSP/1.0" + +enum airplay_events +{ + AIRPLAY_EVENT_UNKNOWN, + AIRPLAY_EVENT_PLAY, + AIRPLAY_EVENT_PAUSE, + AIRPLAY_EVENT_NEXT, + AIRPLAY_EVENT_PREV, +}; + +struct airplay_events_client +{ + char *name; + int fd; + struct event *listener; + struct pair_cipher_context *cipher_ctx; + + struct evbuffer *incoming; + struct evbuffer *pending; + + struct airplay_events_client *next; +}; + +struct rtsp_message +{ + int content_length; + char *content_type; + char *first_line; + int cseq; + + const uint8_t *body; + size_t bodylen; + + const uint8_t *data; + size_t datalen; +}; + + +static pthread_t thread_id; +static struct event_base *evbase; +static struct commands_base *cmdbase; +static struct airplay_events_client *airplay_events_clients; + +// Forwards +static void +incoming_cb(int fd, short what, void *arg); + + +/* ---------------------------- Client handling ----------------------------- */ + +static void +client_free(struct airplay_events_client *client) +{ + if (!client) + return; + + if (client->listener) + event_free(client->listener); + + evbuffer_free(client->incoming); + evbuffer_free(client->pending); + + free(client->name); + pair_cipher_free(client->cipher_ctx); + + free(client); +} + +static void +client_remove(struct airplay_events_client *client) +{ + struct airplay_events_client *iter; + + if (client == airplay_events_clients) + airplay_events_clients = client->next; + else + { + for (iter = airplay_events_clients; iter && (iter->next != client); iter = iter->next) + ; /* EMPTY */ + + if (iter) + iter->next = client->next; + } + + client_free(client); +} + +static int +client_add(const char *name, int fd, const uint8_t *key, size_t key_len) +{ + struct airplay_events_client *client; + + CHECK_NULL(L_AIRPLAY, client = calloc(1, sizeof(struct airplay_events_client))); + CHECK_NULL(L_AIRPLAY, client->name = strdup(name)); + CHECK_NULL(L_AIRPLAY, client->incoming = evbuffer_new()); + CHECK_NULL(L_AIRPLAY, client->pending = evbuffer_new()); + + client->fd = fd; + + client->listener = event_new(evbase, fd, EV_READ | EV_PERSIST, incoming_cb, client); + if (!client->listener) + { + DPRINTF(E_LOG, L_AIRPLAY, "Could not listen for AirPlay events from '%s', invalid fd or out of memory\n", name); + goto error; + } + + client->cipher_ctx = pair_cipher_new(PAIR_CLIENT_HOMEKIT_NORMAL, 1, key, key_len); + if (!client->cipher_ctx) + { + DPRINTF(E_LOG, L_AIRPLAY, "Could not listen for AirPlay events from '%s': Could not create ciphering context\n", name); + goto error; + } + + event_add(client->listener, NULL); + + client->next = airplay_events_clients; + airplay_events_clients = client; + + return 0; + + error: + client_free(client); + return -1; +} + + +/* -------------------------------- Ciphering ------------------------------- */ + +static int +buffer_decrypt(struct evbuffer *output, struct evbuffer *input, struct pair_cipher_context *cipher_ctx) +{ + uint8_t *in; + size_t in_len; + ssize_t bytes_decrypted; + uint8_t *plain; + size_t plain_len; + + in = evbuffer_pullup(input, -1); + in_len = evbuffer_get_length(input); + + // Note that bytes_decrypted is not necessarily equal to plain_len + bytes_decrypted = pair_decrypt(&plain, &plain_len, in, in_len, cipher_ctx); + if (bytes_decrypted < 0) + return -1; + + evbuffer_add(output, plain, plain_len); + evbuffer_drain(input, bytes_decrypted); + free(plain); + return 0; +} + +static int +buffer_encrypt(struct evbuffer *output, uint8_t *in, size_t in_len, struct pair_cipher_context *cipher_ctx) +{ + uint8_t *out; + size_t out_len; + int ret; + + ret = pair_encrypt(&out, &out_len, in, in_len, cipher_ctx); + if (ret < 0) + return -1; + + evbuffer_add(output, out, out_len); + free(out); + return 0; +} + + +/* --------------------- Message construction/parsing ----------------------- */ + +static void +response_headers_add(struct evbuffer *response, int cseq, size_t content_length, const char *content_type) +{ + evbuffer_add_printf(response, "%s 200 OK\r\n", RTSP_VERSION); + evbuffer_add_printf(response, "Server: %s/1.0\r\n", PACKAGE_NAME); + if (content_length) + evbuffer_add_printf(response, "Content-Length: %zu\r\n", content_length); + if (content_type) + evbuffer_add_printf(response, "Content-Type: %s\r\n", content_type); + if (cseq) + evbuffer_add_printf(response, "CSeq: %d\r\n", cseq); + evbuffer_add_printf(response, "\r\n"); +} + +static void +response_create_from_raw(struct evbuffer *response, uint8_t *body, size_t body_len, int cseq, const char *content_type) +{ + response_headers_add(response, cseq, body_len, content_type); + + if (body) + evbuffer_add(response, body, body_len); +} + +static int +body_find(uint8_t **body, size_t *body_len, uint8_t *in, size_t in_len) +{ + const char *plist_header = "bplist"; + size_t plist_header_len = strlen(plist_header); + + *body_len = in_len; + for (*body = in; *body_len > plist_header_len; (*body)++, (*body_len)--) + { + if (memcmp(*body, plist_header, plist_header_len) == 0) + return 0; + } + + return -1; +} + +static int +rtsp_parse(enum airplay_events *event, uint8_t *in, size_t in_len) +{ + uint8_t *body; + size_t body_len; + plist_t request = NULL; + plist_t item; + char *type = NULL; + char *value = NULL; + int ret; + + DHEXDUMP(E_DBG, L_AIRPLAY, in, in_len, "Incoming event\n"); + + ret = body_find(&body, &body_len, in, in_len); + if (ret < 0) + { + DPRINTF(E_WARN, L_AIRPLAY, "Could not parse incoming event, no plist body found\n"); + return -1; + } + + plist_from_bin((char *)body, (uint32_t)body_len, &request); + if (!request) + { + DPRINTF(E_WARN, L_AIRPLAY, "Could not parse incoming event plist\n"); + return -1; + } + + // TODO remove + char *xml = NULL; + uint32_t xml_len; + plist_to_xml(request, &xml, &xml_len); + DPRINTF(E_DBG, L_AIRPLAY, "%s\n", xml); + + item = plist_dict_get_item(request, "type"); + if (item) + { + plist_get_string_val(item, &type); + } + + item = plist_dict_get_item(request, "value"); + if (item) + { + plist_get_string_val(item, &value); + } + + if (!type || !value) + { + DPRINTF(E_DBG, L_AIRPLAY, "AirPlay event has no type/value: type=%s, value=%s\n", type, value); + goto error; + } + else if (strcmp(type, "sendMediaRemoteCommand") != 0) + { + DPRINTF(E_DBG, L_AIRPLAY, "Incoming event not of type sendMediaRemoteCommand\n"); + goto error; + } + + DPRINTF(E_INFO, L_AIRPLAY, "Received event type '%s', value '%s'\n", type, value); + + if (strcmp(value, "paus") == 0) + *event = AIRPLAY_EVENT_PAUSE; + else if (strcmp(value, "play") == 0) + *event = AIRPLAY_EVENT_PLAY; + else if (strcmp(value, "nitm") == 0) + *event = AIRPLAY_EVENT_NEXT; + else if (strcmp(value, "pitm") == 0) + *event = AIRPLAY_EVENT_PREV; + else + *event = AIRPLAY_EVENT_UNKNOWN; + + free(type); + free(value); + plist_free(request); + return 0; + + error: + free(type); + free(value); + plist_free(request); + return -1; +} + + +/* --------------------------- Message handling ----------------------------- */ + +static void +handle_event(enum airplay_events event) +{ + struct player_status status; + + player_get_status(&status); + + switch (event) + { + case AIRPLAY_EVENT_PLAY: + case AIRPLAY_EVENT_PAUSE: + if (status.status == PLAY_PLAYING) + player_playback_pause(); + else + player_playback_start(); + break; + case AIRPLAY_EVENT_NEXT: + player_playback_next(); + break; + case AIRPLAY_EVENT_PREV: + player_playback_prev(); + break; + default: + return; + } +} + +static int +respond(struct airplay_events_client *client) +{ + struct evbuffer *response; + struct evbuffer *encrypted; + uint8_t *plain; + size_t plain_len; + int ret; + + CHECK_NULL(L_AIRPLAY, response = evbuffer_new()); + response_create_from_raw(response, NULL, 0, 0, NULL); + + plain = evbuffer_pullup(response, -1); + plain_len = evbuffer_get_length(response); + + CHECK_NULL(L_AIRPLAY, encrypted = evbuffer_new()); + + ret = buffer_encrypt(encrypted, plain, plain_len, client->cipher_ctx); + if (ret < 0) + { + DPRINTF(E_WARN, L_AIRPLAY, "Could not encrypt AirPlay event data response: %s\n", pair_cipher_errmsg(client->cipher_ctx)); + return -1; + } + + do + { + ret = evbuffer_write(encrypted, client->fd); + if (ret <= 0) + goto error; + } while (evbuffer_get_length(encrypted) > 0); + + evbuffer_free(encrypted); + evbuffer_free(response); + return 0; + + error: + evbuffer_free(encrypted); + evbuffer_free(response); + return -1; +} + +static void +incoming_cb(int fd, short what, void *arg) +{ + struct airplay_events_client *client = arg; + enum airplay_events event; + uint8_t *plain; + size_t plain_len; + int ret; + + DPRINTF(E_DBG, L_AIRPLAY, "AirPlay event from '%s'\n", client->name); + + ret = evbuffer_read(client->incoming, fd, -1); + if (ret == 0) + { + DPRINTF(E_DBG, L_AIRPLAY, "'%s' disconnected from the event channel\n", client->name); + goto disconnect; + } + else if (ret < 0) + { + DPRINTF(E_WARN, L_AIRPLAY, "AirPlay event connection to '%s' returned an error\n", client->name); + goto disconnect; + } + + ret = buffer_decrypt(client->pending, client->incoming, client->cipher_ctx); + if (ret < 0) + { + DPRINTF(E_WARN, L_AIRPLAY, "Could not decrypt incoming AirPlay event data: %s\n", pair_cipher_errmsg(client->cipher_ctx)); + goto disconnect; + } + + plain = evbuffer_pullup(client->pending, -1); + plain_len = evbuffer_get_length(client->pending); + + ret = rtsp_parse(&event, plain, plain_len); + if (ret < 0) // A message type we don't know about, so ignore + { + evbuffer_drain(client->pending, -1); + return; + } + else if (ret == 1) + { + DPRINTF(E_SPAM, L_AIRPLAY, "Incomplete RTSP event message, waiting for more data\n"); + return; + } + + evbuffer_drain(client->pending, -1); + + handle_event(event); + + ret = respond(client); + if (ret < 0) + { + DPRINTF(E_WARN, L_AIRPLAY, "Could not send AirPlay event response\n"); + goto disconnect; + } + + return; + + disconnect: + client_remove(client); + return; +} + +/* -------------------- Event loop (thread: airplay events ) ---------------- */ + +static void * +airplay_events(void *arg) +{ + event_base_dispatch(evbase); + + pthread_exit(NULL); +} + + +/* ------------------------------- Interface -------------------------------- */ + +int +airplay_events_listen(const char *name, const char *address, unsigned short port, const uint8_t *key, size_t key_len) +{ + int fd; + int ret; + + fd = net_connect(address, port, SOCK_STREAM, "AirPlay events"); + if (fd < 0) + { + return -1; + } + + ret = client_add(name, fd, key, key_len); + if (ret < 0) + { + close(fd); + return -1; + } + + return fd; +} + +/* Thread: main */ +int +airplay_events_init(void) +{ + int ret; + + CHECK_NULL(L_AIRPLAY, evbase = event_base_new()); + CHECK_NULL(L_AIRPLAY, cmdbase = commands_base_new(evbase, NULL)); + + DPRINTF(E_INFO, L_AIRPLAY, "AirPlay events thread init\n"); + + ret = pthread_create(&thread_id, NULL, airplay_events, NULL); + if (ret < 0) + { + DPRINTF(E_LOG, L_AIRPLAY, "Could not spawn AirPlay events thread: %s\n", strerror(errno)); + + goto error; + } + +// TODO thread_name_set(thread_id, "airplay events"); + + return 0; + + error: + airplay_events_deinit(); + return -1; +} + +/* Thread: main */ +void +airplay_events_deinit(void) +{ + int ret; + + commands_base_destroy(cmdbase); + + ret = pthread_join(thread_id, NULL); + if (ret != 0) + { + DPRINTF(E_LOG, L_AIRPLAY, "Could not join AirPlay events thread: %s\n", strerror(errno)); + return; + } + + while (airplay_events_clients) + { + client_remove(airplay_events_clients); + } + + event_base_free(evbase); +} diff --git a/src/outputs/airplay_events.h b/src/outputs/airplay_events.h new file mode 100644 index 00000000..f71be1d6 --- /dev/null +++ b/src/outputs/airplay_events.h @@ -0,0 +1,13 @@ +#ifndef __AIRPLAY_EVENTS_H__ +#define __AIRPLAY_EVENTS_H__ + +int +airplay_events_listen(const char *name, const char *address, unsigned short port, const uint8_t *key, size_t key_len); + +int +airplay_events_init(void); + +void +airplay_events_deinit(void); + +#endif /* !__AIRPLAY_EVENTS_H__ */