diff --git a/README.md b/README.md index 77689e47..d607bd84 100644 --- a/README.md +++ b/README.md @@ -435,40 +435,46 @@ curl "http://localhost:3689/logout?session-id=50" ## Spotify -forked-daapd has *some* support for Spotify. It must be compiled with the -`--enable-spotify option` (see +forked-daapd has support for playback of the tracks in your Spotify library. It +must have been compiled with the `--enable-spotify` option (see [INSTALL](https://github.com/ejurgensen/forked-daapd/blob/master/INSTALL)). -You must have also have libspotify installed, otherwise the Spotify integration -will not be available. You can get libspotify here: +You must also have libspotify installed, otherwise Spotify integration will not +be available. Unfortunately the library is no longer available from Spotify, and +at the time of writing they have not provided an alternative. You can, however, +still get libspotify here: - - Original (binary) tar.gz, see https://developer.spotify.com - - Debian package (libspotify-dev), see https://apt.mopidy.com +- Debian package (libspotify-dev), see https://apt.mopidy.com You must also have a Spotify premium account. If you normally log into Spotify with your Facebook account you must first go to Spotify's web site where you can get the Spotify username and password that matches your account. With forked-daapd you cannot login into Spotify with your Facebook username/password. -The procedure for logging in to Spotify is very much like the Remote pairing -procedure. You must prepare a file, which should have the ending ".spotify". -The file must have two lines: The first is your Spotify user name, and the -second is your password. Move the file to your forked-daapd library. -Forked-daapd will then log in and add all the music in your Spotify playlists -to its database. +The procedure for logging in to Spotify is a two-step procedure due to the +current state of libspotify: + +1. Put a file in your forked-daapd library containing two lines, the first being + your Spotify user name, and the second your password. The filename must have + the ending ".spotify" +2. Delete the file again - forked-daapd will have read it. +3. forked-daapd will log in and add all music in your Spotify playlists to its + database. Wait until completed (follow progress in the log file). +4. In a browser, go to http://forked-daapd.local:3689/oauth and click the link + to authorize forked-daapd with Spotify. Spotify will automatically notify forked-daapd about playlist updates, so you -should not need to restart forked-daapd to syncronize with Spotify. +should not need to restart forked-daapd to syncronize with Spotify. However, +Spotify only notifies about playlist updates, not new saved tracks/albums, so +you need to repeat step 4 above to load those. -For safety you should delete the ".spotify" file after first login. Forked-daapd -will not store your password, but will still be able to log you in automatically -afterwards, because libspotify saves a login token. You can configure the -location of your Spotify user data in the configuration file. +Forked-daapd will not store your password, but will still be able to log you in +automatically afterwards, because libspotify saves a login token. You can +configure the location of your Spotify user data in the configuration file. To permanently logout and remove credentials, delete the contents of `/var/cache/forked-daapd/libspotify` (while forked-daapd is stopped). -Limitations: You will only be able to play tracks from your Spotify playlists, -so you can't search and listen to music from the rest of the Spotify catalogue. +Limitations: You will not be able to do any playlist management through forked-daapd - use a Spotify client for that. You also can only listen to your music by letting forked-daapd do the playback - so that means you can't stream from forked-daapd diff --git a/configure.ac b/configure.ac index 36578039..1357cb9b 100644 --- a/configure.ac +++ b/configure.ac @@ -159,63 +159,6 @@ AC_CHECK_HEADERS(getopt.h,,) AC_CHECK_HEADERS(stdint.h,,) dnl --- Begin configuring the options --- -dnl iTunes playlists with libplist -AC_ARG_ENABLE(itunes, AS_HELP_STRING([--enable-itunes], [enable iTunes Music Library XML support (default=no)])) -AS_IF([test "x$enable_itunes" = "xyes"], [ - AC_DEFINE(ITUNES, 1, [Define to 1 to enable iTunes XML support]) - PKG_CHECK_MODULES(LIBPLIST, [ libplist >= 0.16 ]) -]) -AM_CONDITIONAL(COND_ITUNES, [test "x$enable_itunes" = "xyes"]) - -dnl Spotify with dynamic linking to libspotify -AC_ARG_ENABLE(spotify, AS_HELP_STRING([--enable-spotify], [enable Spotify support (default=no)])) -AS_IF([test "x$enable_spotify" = "xyes"], [ - AC_DEFINE(SPOTIFY, 1, [Define to 1 to enable Spotify support]) - AC_CHECK_HEADER(libspotify/api.h, , AC_MSG_ERROR([libspotify/api.h not found])) - AC_DEFINE(HAVE_SPOTIFY_H, 1, [Define to 1 if you have the header file.]) - - dnl Don't link to libspotify, but instead enable dynamic linking - SPOTIFY_CFLAGS="-rdynamic" - SPOTIFY_LIBS="-ldl" - AC_SUBST(SPOTIFY_CFLAGS) - AC_SUBST(SPOTIFY_LIBS) -]) -AM_CONDITIONAL(COND_SPOTIFY, [test "x$enable_spotify" = "xyes"]) - -dnl LastFM support with libcurl -AC_ARG_ENABLE(lastfm, AS_HELP_STRING([--enable-lastfm], [enable LastFM support (default=no)])) -AS_IF([test "x$enable_lastfm" = "xyes"], [ - AC_DEFINE(LASTFM, 1, [Define to 1 to enable LastFM support]) - PKG_CHECK_MODULES(LIBCURL, [ libcurl ]) - AC_CHECK_LIB([mxml], [mxmlGetOpaque], AC_DEFINE(HAVE_MXML_GETOPAQUE, 1, [Define to 1 if your mxml has mxmlGetOpaque.])) -]) -AM_CONDITIONAL(COND_LASTFM, [test "x$enable_lastfm" = "xyes"]) - -dnl ChromeCast support with libprotobuf-c -AC_ARG_ENABLE(chromecast, AS_HELP_STRING([--enable-chromecast], [enable ChromeCast support (default=no)])) -AS_IF([test "x$enable_chromecast" = "xyes"], [ - AC_DEFINE(CHROMECAST, 1, [Define to 1 to enable Chromecast support]) - PKG_CHECK_MODULES(LIBPROTOBUF_C, [ libprotobuf-c >= 1.0.0 ], , [ protobuf_old="yes" ]) - PKG_CHECK_MODULES(GNUTLS, [ gnutls ]) - PKG_CHECK_EXISTS([ json-c >= 0.11 ], - [ PKG_CHECK_MODULES(JSON_C, [ json-c ]) ], - [ PKG_CHECK_MODULES(JSON_C, [ json ], AC_DEFINE(HAVE_JSON_C_OLD, 1, [Define 1 to if you have json-c < 0.11])) ] - ) -]) -AS_IF([test "x$protobuf_old" = "xyes"], [ - AC_DEFINE(HAVE_PROTOBUF_OLD, 1, [Define to 1 if you have libprotobuf < 1.0.0]) - LDFLAGS="${LDFLAGS} -lprotobuf-c" -]) -AM_CONDITIONAL(COND_CHROMECAST, [test "x$enable_chromecast" = "xyes"]) -AM_CONDITIONAL(COND_PROTOBUF_OLD, [test "x$protobuf_old" = "xyes"]) - -dnl MPD support -AC_ARG_ENABLE(mpd, AS_HELP_STRING([--disable-mpd], [disable MPD client protocol support (default=no)])) -AS_IF([test "x$enable_mpd" != "xno"], [ - AC_DEFINE(MPD, 1, [Define to 1 to enable MPD support]) -]) -AM_CONDITIONAL(COND_MPD, [test "x$enable_mpd" != "xno"]) - dnl ALSA AC_ARG_WITH(alsa, AS_HELP_STRING([--without-alsa], [without ALSA support (default=no)])) AS_IF([test "x$with_alsa" != "xno"], [ @@ -234,6 +177,78 @@ AS_IF([test "x$with_pulseaudio" = "xyes"], [ ) ]) AM_CONDITIONAL(COND_PULSEAUDIO, [test "x$with_pulseaudio" = "xyes"]) + +dnl Build with libcurl +AC_ARG_WITH([libcurl], AS_HELP_STRING([--without-libcurl], [without libcurl (default=no)])) +AS_IF([test "x$with_libcurl" != "xno"], [ + AC_DEFINE(HAVE_LIBCURL, 1, [Define to 1 to build with libcurl]) + PKG_CHECK_MODULES(LIBCURL, [ libcurl ]) +]) + +dnl Build with json-c +AC_ARG_WITH([json], AS_HELP_STRING([--without-json-c], [without json-c (default=no)])) +AS_IF([test "x$with_json" != "xno"], [ + AC_DEFINE(HAVE_JSON, 1, [Define to 1 to build with json-c]) + PKG_CHECK_EXISTS([ json-c >= 0.11 ], + [ PKG_CHECK_MODULES(JSON_C, [ json-c ]) ], + [ PKG_CHECK_MODULES(JSON_C, [ json ], AC_DEFINE(HAVE_JSON_C_OLD, 1, [Define 1 to if you have json-c < 0.11])) ] + ) +]) + +dnl iTunes playlists with libplist +AC_ARG_ENABLE(itunes, AS_HELP_STRING([--enable-itunes], [enable iTunes Music Library XML support (default=no)])) +AS_IF([test "x$enable_itunes" = "xyes"], [ + AC_DEFINE(ITUNES, 1, [Define to 1 to enable iTunes XML support]) + PKG_CHECK_MODULES(LIBPLIST, [ libplist >= 0.16 ]) +]) +AM_CONDITIONAL(COND_ITUNES, [test "x$enable_itunes" = "xyes"]) + +dnl Spotify with dynamic linking to libspotify +AC_ARG_ENABLE(spotify, AS_HELP_STRING([--enable-spotify], [enable Spotify support (default=no)])) +AS_IF([test "x$enable_spotify" = "xyes"], [ + AC_DEFINE(SPOTIFY, 1, [Define to 1 to enable Spotify support]) + AS_IF([test "x$with_json" = "xno"], AC_MSG_ERROR([Spotify support requires json-c])) + AC_CHECK_HEADER(libspotify/api.h, , AC_MSG_ERROR([libspotify/api.h not found])) + AC_DEFINE(HAVE_SPOTIFY_H, 1, [Define to 1 if you have the header file.]) + + dnl Don't link to libspotify, but instead enable dynamic linking + SPOTIFY_CFLAGS="-rdynamic" + SPOTIFY_LIBS="-ldl" + AC_SUBST(SPOTIFY_CFLAGS) + AC_SUBST(SPOTIFY_LIBS) +]) +AM_CONDITIONAL(COND_SPOTIFY, [test "x$enable_spotify" = "xyes"]) + +dnl LastFM support with libcurl +AC_ARG_ENABLE(lastfm, AS_HELP_STRING([--enable-lastfm], [enable LastFM support (default=no)])) +AS_IF([test "x$enable_lastfm" = "xyes"], [ + AC_DEFINE(LASTFM, 1, [Define to 1 to enable LastFM support]) + AS_IF([test "x$with_libcurl" = "xno"], AC_MSG_ERROR([LastFM support requires libcurl])) + AC_CHECK_LIB([mxml], [mxmlGetOpaque], AC_DEFINE(HAVE_MXML_GETOPAQUE, 1, [Define to 1 if your mxml has mxmlGetOpaque.])) +]) +AM_CONDITIONAL(COND_LASTFM, [test "x$enable_lastfm" = "xyes"]) + +dnl ChromeCast support with libprotobuf-c +AC_ARG_ENABLE(chromecast, AS_HELP_STRING([--enable-chromecast], [enable ChromeCast support (default=no)])) +AS_IF([test "x$enable_chromecast" = "xyes"], [ + AC_DEFINE(CHROMECAST, 1, [Define to 1 to enable Chromecast support]) + AS_IF([test "x$with_json" = "xno"], AC_MSG_ERROR([Chromecast support requires json-c])) + PKG_CHECK_MODULES(LIBPROTOBUF_C, [ libprotobuf-c >= 1.0.0 ], , [ protobuf_old="yes" ]) + PKG_CHECK_MODULES(GNUTLS, [ gnutls ]) +]) +AS_IF([test "x$protobuf_old" = "xyes"], [ + AC_DEFINE(HAVE_PROTOBUF_OLD, 1, [Define to 1 if you have libprotobuf < 1.0.0]) + LDFLAGS="${LDFLAGS} -lprotobuf-c" +]) +AM_CONDITIONAL(COND_CHROMECAST, [test "x$enable_chromecast" = "xyes"]) +AM_CONDITIONAL(COND_PROTOBUF_OLD, [test "x$protobuf_old" = "xyes"]) + +dnl MPD support +AC_ARG_ENABLE(mpd, AS_HELP_STRING([--disable-mpd], [disable MPD client protocol support (default=no)])) +AS_IF([test "x$enable_mpd" != "xno"], [ + AC_DEFINE(MPD, 1, [Define to 1 to enable MPD support]) +]) +AM_CONDITIONAL(COND_MPD, [test "x$enable_mpd" != "xno"]) dnl --- End options --- dnl Checks for header files. diff --git a/src/artwork.c b/src/artwork.c index 525a8396..84be2942 100644 --- a/src/artwork.c +++ b/src/artwork.c @@ -1129,8 +1129,8 @@ source_item_stream_get(struct artwork_ctx *ctx) memset(&client, 0, sizeof(struct http_client_ctx)); client.url = url; - client.headers = kv; - client.body = ctx->evbuf; + client.input_headers = kv; + client.input_body = ctx->evbuf; if (http_client_request(&client) < 0) goto out_kv; diff --git a/src/db.c b/src/db.c index ad161700..60340bcc 100644 --- a/src/db.c +++ b/src/db.c @@ -3906,7 +3906,7 @@ db_spotify_purge(void) ret = db_query_run(queries[i], 0, 1); if (ret == 0) - DPRINTF(E_DBG, L_DB, "Purged %d rows\n", sqlite3_changes(hdl)); + DPRINTF(E_DBG, L_DB, "Processed %d rows\n", sqlite3_changes(hdl)); } // Disable the spotify directory by setting 'disabled' to INOTIFY_FAKE_COOKIE value @@ -3950,7 +3950,7 @@ db_spotify_pl_delete(int id) /* Spotify */ void -db_spotify_files_delete() +db_spotify_files_delete(void) { #define Q_TMPL "DELETE FROM files WHERE path LIKE 'spotify:%%' AND NOT path IN (SELECT filepath FROM playlistitems);" char *query; diff --git a/src/db.h b/src/db.h index 8befd7e1..f541ee10 100644 --- a/src/db.h +++ b/src/db.h @@ -620,7 +620,7 @@ void db_spotify_pl_delete(int id); void -db_spotify_files_delete(); +db_spotify_files_delete(void); #endif /* Admin */ diff --git a/src/filescanner.c b/src/filescanner.c index 1bec8125..e973ab91 100644 --- a/src/filescanner.c +++ b/src/filescanner.c @@ -1222,11 +1222,8 @@ bulk_scan(int flags) else { /* Protect spotify from the imminent purge if rescanning */ - if (flags & F_SCAN_RESCAN) - { - db_file_ping_bymatch("spotify:", 0); - db_pl_ping_bymatch("spotify:", 0); - } + db_file_ping_bymatch("spotify:", 0); + db_pl_ping_bymatch("spotify:", 0); DPRINTF(E_DBG, L_SCAN, "Purging old database content\n"); db_purge_cruft(start); diff --git a/src/http.c b/src/http.c index bc4df65f..9e01d124 100644 --- a/src/http.c +++ b/src/http.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2015 Espen Jürgensen + * Copyright (C) 2016 Espen Jürgensen * * 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 @@ -36,6 +36,10 @@ #include +#ifdef HAVE_LIBCURL +#include +#endif + #include "http.h" #include "logger.h" #include "misc.h" @@ -128,10 +132,10 @@ request_cb(struct evhttp_request *req, void *arg) ctx->ret = 0; - if (ctx->headers) - headers_save(ctx->headers, evhttp_request_get_input_headers(req)); - if (ctx->body) - evbuffer_add_buffer(ctx->body, evhttp_request_get_input_buffer(req)); + if (ctx->input_headers) + headers_save(ctx->input_headers, evhttp_request_get_input_headers(req)); + if (ctx->input_body) + evbuffer_add_buffer(ctx->input_body, evhttp_request_get_input_buffer(req)); event_base_loopbreak(ctx->evbase); @@ -171,8 +175,8 @@ request_header_cb(struct evhttp_request *req, void *arg) } #endif -int -http_client_request(struct http_client_ctx *ctx) +static int +http_client_request_impl(struct http_client_ctx *ctx) { struct evhttp_connection *evcon; struct evhttp_request *req; @@ -269,6 +273,144 @@ http_client_request(struct http_client_ctx *ctx) return ctx->ret; } +#ifdef HAVE_LIBCURL +static size_t +curl_request_cb(char *ptr, size_t size, size_t nmemb, void *userdata) +{ + size_t realsize; + struct http_client_ctx *ctx; + int ret; + + realsize = size * nmemb; + ctx = (struct http_client_ctx *)userdata; + + if (!ctx->input_body) + return realsize; + + ret = evbuffer_add(ctx->input_body, ptr, realsize); + if (ret < 0) + { + DPRINTF(E_LOG, L_HTTP, "Error adding reply from %s to input buffer\n", ctx->url); + return 0; + } + + return realsize; +} + +static int +https_client_request_impl(struct http_client_ctx *ctx) +{ + CURL *curl; + CURLcode res; + struct curl_slist *headers; + struct onekeyval *okv; + char header[1024]; + + curl = curl_easy_init(); + if (!curl) + { + DPRINTF(E_LOG, L_HTTP, "Error: Could not get curl handle\n"); + return -1; + } + + curl_easy_setopt(curl, CURLOPT_URL, ctx->url); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "forked-daapd/" VERSION); + + if (ctx->output_headers) + { + headers = NULL; + for (okv = ctx->output_headers->head; okv; okv = okv->next) + { + snprintf(header, sizeof(header), "%s: %s", okv->name, okv->value); + headers = curl_slist_append(headers, header); + } + + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + } + + if (ctx->output_body) + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, ctx->output_body); + + curl_easy_setopt(curl, CURLOPT_TIMEOUT, HTTP_CLIENT_TIMEOUT); + + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_request_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, ctx); + + /* Make request */ + DPRINTF(E_INFO, L_HTTP, "Making request for %s\n", ctx->url); + + res = curl_easy_perform(curl); + if (res != CURLE_OK) + { + DPRINTF(E_LOG, L_HTTP, "Request to %s failed: %s\n", ctx->url, curl_easy_strerror(res)); + curl_easy_cleanup(curl); + return -1; + } + + curl_easy_cleanup(curl); + + return 0; +} +#endif /* HAVE_LIBCURL */ + +int +http_client_request(struct http_client_ctx *ctx) +{ + if (strncmp(ctx->url, "http:", strlen("http:")) == 0) + return http_client_request_impl(ctx); + +#ifdef HAVE_LIBCURL + if (strncmp(ctx->url, "https:", strlen("https:")) == 0) + return https_client_request_impl(ctx); +#endif + + DPRINTF(E_LOG, L_HTTP, "Request for %s is not supported (not built with libcurl?)\n", ctx->url); + return -1; +} + +char * +http_form_urlencode(struct keyval *kv) +{ + struct evbuffer *evbuf; + struct onekeyval *okv; + char *body; + char *k; + char *v; + + evbuf = evbuffer_new(); + + for (okv = kv->head; okv; okv = okv->next) + { + k = evhttp_encode_uri(okv->name); + if (!k) + continue; + + v = evhttp_encode_uri(okv->value); + if (!v) + { + free(k); + continue; + } + + evbuffer_add_printf(evbuf, "%s=%s", k, v); + if (okv->next) + evbuffer_add_printf(evbuf, "&"); + + free(k); + free(v); + } + + evbuffer_add(evbuf, "\n", 1); + + body = evbuffer_readln(evbuf, NULL, EVBUFFER_EOL_ANY); + + evbuffer_free(evbuf); + + DPRINTF(E_DBG, L_HTTP, "Parameters in request are: %s\n", body); + + return body; +} + int http_stream_setup(char **stream, const char *url) { @@ -296,7 +438,7 @@ http_stream_setup(char **stream, const char *url) return -1; ctx.url = url; - ctx.body = evbuf; + ctx.input_body = evbuf; ret = http_client_request(&ctx); if (ret < 0) @@ -308,13 +450,13 @@ http_stream_setup(char **stream, const char *url) } // Pad with CRLF because evbuffer_readln() might not read the last line otherwise - evbuffer_add(ctx.body, "\r\n", 2); + evbuffer_add(ctx.input_body, "\r\n", 2); /* Read the playlist until the first stream link is found, but give up if * nothing is found in the first 10 lines */ n = 0; - while ((line = evbuffer_readln(ctx.body, NULL, EVBUFFER_EOL_ANY)) && (n < 10)) + while ((line = evbuffer_readln(ctx.input_body, NULL, EVBUFFER_EOL_ANY)) && (n < 10)) { n++; if (strncasecmp(line, "http://", strlen("http://")) == 0) @@ -328,7 +470,7 @@ http_stream_setup(char **stream, const char *url) free(line); } - evbuffer_free(ctx.body); + evbuffer_free(ctx.input_body); if (n != -1) { diff --git a/src/http.h b/src/http.h index 62ba912b..6999be95 100644 --- a/src/http.h +++ b/src/http.h @@ -10,13 +10,18 @@ struct http_client_ctx { + /* Destination URL, header and body of outgoing request body (headers and + * body is currently only supported for https) + */ const char *url; + struct keyval *output_headers; + char *output_body; /* A keyval/evbuf to store response headers and body. * Can be set to NULL to ignore that part of the response. */ - struct keyval *headers; - struct evbuffer *body; + struct keyval *input_headers; + struct evbuffer *input_body; /* Cut the connection after the headers have been received * Used for getting Shoutcast/ICY headers for old versions of libav/ffmpeg @@ -47,15 +52,27 @@ struct http_icy_metadata }; -/* Generic HTTP client. No support for https. +/* Make a http(s) request. We use libcurl to make https requests. We could use + * libevent and avoid the dependency, but for SSL, libevent needs to be v2.1 + * or better, which is still a bit too new to be in the major distros. * * @param ctx HTTP request params, see above - * @return 0 if successful, -1 if an error occurred + * @return 0 if successful, -1 if an error occurred (e.g. no libcurl) */ int http_client_request(struct http_client_ctx *ctx); +/* Converts the keyval dictionary to a application/x-www-form-urlencoded string. + * The values will be uri_encoded. Example output: "key1=foo%20bar&key2=123". + * + * @param kv is the struct containing the parameters + * @return encoded string if succesful, NULL if an error occurred + */ +char * +http_form_urlencode(struct keyval *kv); + + /* Returns a newly allocated string with the first stream in the m3u given in * url. If url is not a m3u, the string will be a copy of url. * diff --git a/src/httpd.c b/src/httpd.c index b8bcd119..05ba667e 100644 --- a/src/httpd.c +++ b/src/httpd.c @@ -64,6 +64,9 @@ #ifdef LASTFM # include "lastfm.h" #endif +#ifdef HAVE_SPOTIFY_H +# include "spotify.h" +#endif /* * HTTP client quirks by User-Agent, from mt-daapd @@ -140,6 +143,7 @@ static struct evhttp *evhttpd; static pthread_t tid_httpd; static char *allow_origin; +static int httpd_port; #ifdef HAVE_LIBEVENT2_OLD struct stream_ctx *g_st; @@ -198,6 +202,61 @@ scrobble_cb(void *arg) } #endif +static void +oauth_interface(struct evhttp_request *req, const char *uri) +{ + struct evbuffer *evbuf; + struct evkeyvalq query; + const char *req_uri; + const char *ptr; + char redirect_uri[256]; + int ret; + + req_uri = evhttp_request_get_uri(req); + + evbuf = evbuffer_new(); + if (!evbuf) + { + DPRINTF(E_LOG, L_HTTPD, "Could not alloc evbuf for oauth\n"); + return; + } + + evbuffer_add_printf(evbuf, "

forked-daapd oauth

\n\n"); + + memset(&query, 0, sizeof(struct evkeyvalq)); + + ptr = strchr(req_uri, '?'); + if (ptr) + { + ret = evhttp_parse_query_str(ptr + 1, &query); + if (ret < 0) + { + evbuffer_add_printf(evbuf, "OAuth error: Could not parse parameters in callback (%s)\n", req_uri); + + httpd_send_reply(req, HTTP_OK, "OK", evbuf, 0); + evbuffer_free(evbuf); + return; + } + } + +#ifdef HAVE_SPOTIFY_H + snprintf(redirect_uri, sizeof(redirect_uri), "http://forked-daapd.local:%d/oauth/spotify", httpd_port); + + if (strncmp(uri, "/oauth/spotify", strlen("/oauth/spotify")) == 0) + spotify_oauth_callback(evbuf, &query, redirect_uri); + else + spotify_oauth_interface(evbuf, redirect_uri); +#endif + + evbuffer_add_printf(evbuf, "

(sorry about this ugly interface)

\n"); + + evhttp_clear_headers(&query); + + httpd_send_reply(req, HTTP_OK, "OK", evbuf, 0); + + evbuffer_free(evbuf); +} + static void stream_end_register(struct stream_ctx *st) { @@ -912,6 +971,12 @@ serve_file(struct evhttp_request *req, char *uri) } } + if (strncmp(uri, "/oauth", strlen("/oauth")) == 0) + { + oauth_interface(req, uri); + return; + } + ret = snprintf(path, sizeof(path), "%s%s", WEBFACE_ROOT, uri + 1); /* skip starting '/' */ if ((ret < 0) || (ret >= sizeof(path))) { @@ -1347,7 +1412,6 @@ int httpd_init(void) { int v6enabled; - unsigned short port; int ret; httpd_exit = 0; @@ -1428,7 +1492,7 @@ httpd_init(void) } v6enabled = cfg_getbool(cfg_getsec(cfg, "general"), "ipv6"); - port = cfg_getint(cfg_getsec(cfg, "library"), "port"); + httpd_port = cfg_getint(cfg_getsec(cfg, "library"), "port"); // For CORS headers allow_origin = cfg_getstr(cfg_getsec(cfg, "general"), "allow_origin"); @@ -1442,20 +1506,20 @@ httpd_init(void) if (v6enabled) { - ret = evhttp_bind_socket(evhttpd, "::", port); + ret = evhttp_bind_socket(evhttpd, "::", httpd_port); if (ret < 0) { - DPRINTF(E_LOG, L_HTTPD, "Could not bind to port %d with IPv6, falling back to IPv4\n", port); + DPRINTF(E_LOG, L_HTTPD, "Could not bind to port %d with IPv6, falling back to IPv4\n", httpd_port); v6enabled = 0; } } if (!v6enabled) { - ret = evhttp_bind_socket(evhttpd, "0.0.0.0", port); + ret = evhttp_bind_socket(evhttpd, "0.0.0.0", httpd_port); if (ret < 0) { - DPRINTF(E_FATAL, L_HTTPD, "Could not bind to port %d (forked-daapd already running?)\n", port); + DPRINTF(E_FATAL, L_HTTPD, "Could not bind to port %d (forked-daapd already running?)\n", httpd_port); goto bind_fail; } } diff --git a/src/lastfm.c b/src/lastfm.c index 8b99fa88..d1a5a15f 100644 --- a/src/lastfm.c +++ b/src/lastfm.c @@ -39,14 +39,7 @@ #include "lastfm.h" #include "logger.h" #include "misc.h" - - -struct https_client_ctx -{ - const char *url; - const char *body; - struct evbuffer *data; -}; +#include "http.h" // LastFM becomes disabled if we get a scrobble, try initialising session, // but can't (probably no session key in db because user does not use LastFM) @@ -172,51 +165,6 @@ credentials_read(char *path, char **username, char **password) return 0; } -/* Converts parameters to a string in application/x-www-form-urlencoded format */ -static int -body_print(char **body, struct keyval *kv) -{ - struct evbuffer *evbuf; - struct onekeyval *okv; - char *k; - char *v; - - evbuf = evbuffer_new(); - - for (okv = kv->head; okv; okv = okv->next) - { - k = evhttp_encode_uri(okv->name); - if (!k) - continue; - - v = evhttp_encode_uri(okv->value); - if (!v) - { - free(k); - continue; - } - - evbuffer_add(evbuf, k, strlen(k)); - evbuffer_add(evbuf, "=", 1); - evbuffer_add(evbuf, v, strlen(v)); - if (okv->next) - evbuffer_add(evbuf, "&", 1); - - free(k); - free(v); - } - - evbuffer_add(evbuf, "\n", 1); - - *body = evbuffer_readln(evbuf, NULL, EVBUFFER_EOL_ANY); - - evbuffer_free(evbuf); - - DPRINTF(E_DBG, L_LASTFM, "Parameters in request are: %s\n", *body); - - return 0; -} - /* Creates an md5 signature of the concatenated parameters and adds it to keyval */ static int param_sign(struct keyval *kv) @@ -288,28 +236,8 @@ mxmlGetOpaque(mxml_node_t *node) /* I - Node to get */ /* --------------------------------- MAIN --------------------------------- */ -static size_t -request_cb(char *ptr, size_t size, size_t nmemb, void *userdata) -{ - size_t realsize; - struct https_client_ctx *ctx; - int ret; - - realsize = size * nmemb; - ctx = (struct https_client_ctx *)userdata; - - ret = evbuffer_add(ctx->data, ptr, realsize); - if (ret < 0) - { - DPRINTF(E_LOG, L_LASTFM, "Error adding reply from LastFM to data buffer\n"); - return 0; - } - - return realsize; -} - static void -response_proces(struct https_client_ctx *ctx) +response_proces(struct http_client_ctx *ctx) { mxml_node_t *tree; mxml_node_t *s_node; @@ -319,9 +247,9 @@ response_proces(struct https_client_ctx *ctx) char *sk; // NULL-terminate the buffer - evbuffer_add(ctx->data, "", 1); + evbuffer_add(ctx->input_body, "", 1); - body = (char *)evbuffer_pullup(ctx->data, -1); + body = (char *)evbuffer_pullup(ctx->input_body, -1); if (!body || (strlen(body) == 0)) { DPRINTF(E_LOG, L_LASTFM, "Empty response\n"); @@ -380,59 +308,10 @@ response_proces(struct https_client_ctx *ctx) mxmlDelete(tree); } -// We use libcurl to make the request. We could use libevent and avoid the -// dependency, but for SSL, libevent needs to be v2.1 or better, which is still -// a bit too new to be in the major distros -static int -https_client_request(struct https_client_ctx *ctx) -{ - CURL *curl; - CURLcode res; - - curl = curl_easy_init(); - if (!curl) - { - DPRINTF(E_LOG, L_LASTFM, "Error: Could not get curl handle\n"); - return -1; - } - - curl_easy_setopt(curl, CURLOPT_USERAGENT, "libcurl-agent/1.0"); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, ctx->body); - curl_easy_setopt(curl, CURLOPT_URL, ctx->url); - - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, request_cb); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, ctx); - - ctx->data = evbuffer_new(); - if (!ctx->data) - { - DPRINTF(E_LOG, L_LASTFM, "Could not create evbuffer for LastFM response\n"); - curl_easy_cleanup(curl); - return -1; - } - - res = curl_easy_perform(curl); - if (res != CURLE_OK) - { - DPRINTF(E_LOG, L_LASTFM, "Request to %s failed: %s\n", ctx->url, curl_easy_strerror(res)); - curl_easy_cleanup(curl); - return -1; - } - - response_proces(ctx); - - evbuffer_free(ctx->data); - - curl_easy_cleanup(curl); - - return 0; -} - static int request_post(char *method, struct keyval *kv, int auth) { - struct https_client_ctx ctx; - char *body; + struct http_client_ctx ctx; int ret; ret = keyval_add(kv, "method", method); @@ -453,18 +332,27 @@ request_post(char *method, struct keyval *kv, int auth) return -1; } - ret = body_print(&body, kv); + memset(&ctx, 0, sizeof(struct http_client_ctx)); + + ctx.output_body = http_form_urlencode(kv); if (ret < 0) { - DPRINTF(E_LOG, L_LASTFM, "Aborting request, body_print failed\n"); + DPRINTF(E_LOG, L_LASTFM, "Aborting request, http_form_urlencode failed\n"); return -1; } - memset(&ctx, 0, sizeof(struct https_client_ctx)); ctx.url = auth ? auth_url : api_url; - ctx.body = body; + ctx.input_body = evbuffer_new(); - ret = https_client_request(&ctx); + ret = http_client_request(&ctx); + if (ret < 0) + goto out_free_ctx; + + response_proces(&ctx); + + out_free_ctx: + free(ctx.output_body); + evbuffer_free(ctx.input_body); return ret; } diff --git a/src/main.c b/src/main.c index f7da0350..46c71948 100644 --- a/src/main.c +++ b/src/main.c @@ -68,7 +68,7 @@ GCRY_THREAD_OPTION_PTHREAD_IMPL; #include "player.h" #include "worker.h" -#ifdef LASTFM +#ifdef HAVE_LIBCURL # include #endif #ifdef HAVE_SPOTIFY_H @@ -632,7 +632,7 @@ main(int argc, char **argv) #endif av_log_set_callback(logger_ffmpeg); -#ifdef LASTFM +#ifdef HAVE_LIBCURL /* Initialize libcurl */ curl_global_init(CURL_GLOBAL_DEFAULT); #endif @@ -810,6 +810,9 @@ main(int argc, char **argv) goto mdns_reg_fail; } + /* Register this CNAME with mDNS for OAuth */ + mdns_cname("forked-daapd.local"); + #if defined(__linux__) /* Set up signal fd */ sigfd = signalfd(-1, &sigs, SFD_NONBLOCK | SFD_CLOEXEC); @@ -936,7 +939,7 @@ main(int argc, char **argv) signal_block_fail: gcrypt_init_fail: -#ifdef LASTFM +#ifdef HAVE_LIBCURL curl_global_cleanup(); #endif #if LIBAVFORMAT_VERSION_MAJOR >= 54 || (LIBAVFORMAT_VERSION_MAJOR == 53 && LIBAVFORMAT_VERSION_MINOR >= 13) diff --git a/src/mdns.h b/src/mdns.h index 656968ba..cfc8ea67 100644 --- a/src/mdns.h +++ b/src/mdns.h @@ -37,6 +37,16 @@ mdns_deinit(void); int mdns_register(char *name, char *type, int port, char **txt); +/* + * Register a CNAME record, it will be an alias for hostname + * Call only from the main thread! + * + * @in name The CNAME alias, e.g. "forked-daapd.local" + * @return 0 on success, -1 on error + */ +int +mdns_cname(char *name); + /* * Start a service browser, a callback will be made when the service changes state * Call only from the main thread! diff --git a/src/mdns_avahi.c b/src/mdns_avahi.c index cdac6e9b..95ae94ec 100644 --- a/src/mdns_avahi.c +++ b/src/mdns_avahi.c @@ -33,6 +33,7 @@ #include #include #include +#include #include @@ -351,8 +352,15 @@ struct mdns_record_browser { int port; }; +enum publish +{ + MDNS_PUBLISH_SERVICE, + MDNS_PUBLISH_CNAME, +}; + struct mdns_group_entry { + enum publish publish; char *name; char *type; int port; @@ -622,13 +630,100 @@ entry_group_callback(AvahiEntryGroup *g, AvahiEntryGroupState state, AVAHI_GCC_U } } -static void -_create_services(void) +static int +create_group_entry(struct mdns_group_entry *ge, int commit) { - struct mdns_group_entry *pentry; + char hostname[HOST_NAME_MAX + 1]; + char rdata[HOST_NAME_MAX + 6 + 1]; // Includes room for ".local" and 0-terminator + int count; + int i; int ret; - DPRINTF(E_DBG, L_MDNS, "Creating service group\n"); + if (!mdns_group) + { + mdns_group = avahi_entry_group_new(mdns_client, entry_group_callback, NULL); + if (!mdns_group) + { + DPRINTF(E_WARN, L_MDNS, "Could not create Avahi EntryGroup: %s\n", MDNSERR); + return -1; + } + } + + if (ge->publish == MDNS_PUBLISH_SERVICE) + { + DPRINTF(E_DBG, L_MDNS, "Adding service %s/%s\n", ge->name, ge->type); + + ret = avahi_entry_group_add_service_strlst(mdns_group, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, 0, + ge->name, ge->type, + NULL, NULL, ge->port, ge->txt); + if (ret < 0) + { + DPRINTF(E_LOG, L_MDNS, "Could not add mDNS service %s/%s: %s\n", ge->name, ge->type, avahi_strerror(ret)); + return -1; + } + } + else if (ge->publish == MDNS_PUBLISH_CNAME) + { + DPRINTF(E_DBG, L_MDNS, "Adding CNAME record %s\n", ge->name); + + ret = gethostname(hostname, HOST_NAME_MAX); + if (ret < 0) + { + DPRINTF(E_LOG, L_MDNS, "Could not add CNAME %s, gethostname failed\n", ge->name); + return -1; + } + + // Note, gethostname does not guarantee 0-termination + ret = snprintf(rdata, sizeof(rdata), ".%s.local", hostname); + if (!(ret > 0 && ret < sizeof(rdata))) + { + DPRINTF(E_LOG, L_MDNS, "Could not add CNAME %s, hostname is invalid\n", ge->name); + return -1; + } + + // Convert to dns string: .forked-daapd.local -> \12forked-daapd\6local + count = 0; + for (i = ret - 1; i >= 0; i--) + { + if (rdata[i] == '.') + { + rdata[i] = count; + count = 0; + } + else + count++; + } + + // ret + 1 should be the string length of rdata incl. 0-terminator + ret = avahi_entry_group_add_record(mdns_group, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, + AVAHI_PUBLISH_USE_MULTICAST | AVAHI_PUBLISH_ALLOW_MULTIPLE, + ge->name, AVAHI_DNS_CLASS_IN, AVAHI_DNS_TYPE_CNAME, + AVAHI_DEFAULT_TTL, rdata, ret + 1); + if (ret < 0) + { + DPRINTF(E_LOG, L_MDNS, "Could not add CNAME record %s: %s\n", ge->name, avahi_strerror(ret)); + return -1; + } + } + + if (!commit) + return 0; + + ret = avahi_entry_group_commit(mdns_group); + if (ret < 0) + { + DPRINTF(E_LOG, L_MDNS, "Could not commit mDNS services: %s\n", MDNSERR); + return -1; + } + + return 0; +} + +static void +create_all_group_entries(void) +{ + struct mdns_group_entry *ge; + int ret; if (!group_entries) { @@ -636,36 +731,21 @@ _create_services(void) return; } - if (mdns_group == NULL) - { - mdns_group = avahi_entry_group_new(mdns_client, entry_group_callback, NULL); - if (!mdns_group) - { - DPRINTF(E_WARN, L_MDNS, "Could not create Avahi EntryGroup: %s\n", MDNSERR); - return; - } - } + if (mdns_group) + avahi_entry_group_reset(mdns_group); - pentry = group_entries; - while (pentry) - { - DPRINTF(E_DBG, L_MDNS, "Re-registering %s/%s\n", pentry->name, pentry->type); + DPRINTF(E_INFO, L_MDNS, "Re-registering mDNS groups (services and records)\n"); - ret = avahi_entry_group_add_service_strlst(mdns_group, AVAHI_IF_UNSPEC, AVAHI_PROTO_UNSPEC, 0, - pentry->name, pentry->type, - NULL, NULL, pentry->port, pentry->txt); - if (ret < 0) - { - DPRINTF(E_WARN, L_MDNS, "Could not add mDNS services: %s\n", avahi_strerror(ret)); - return; - } + for (ge = group_entries; ge; ge = ge->next) + { + create_group_entry(ge, 0); + if (!mdns_group) + return; + } - pentry = pentry->next; - } - - ret = avahi_entry_group_commit(mdns_group); - if (ret < 0) - DPRINTF(E_WARN, L_MDNS, "Could not commit mDNS services: %s\n", MDNSERR); + ret = avahi_entry_group_commit(mdns_group); + if (ret < 0) + DPRINTF(E_WARN, L_MDNS, "Could not commit mDNS services: %s\n", MDNSERR); } static void @@ -680,7 +760,7 @@ client_callback(AvahiClient *c, AvahiClientState state, AVAHI_GCC_UNUSED void * case AVAHI_CLIENT_S_RUNNING: DPRINTF(E_LOG, L_MDNS, "Avahi state change: Client running\n"); if (!mdns_group) - _create_services(); + create_all_group_entries(); for (mb = browser_list; mb; mb = mb->next) { @@ -808,8 +888,6 @@ mdns_register(char *name, char *type, int port, char **txt) AvahiStringList *txt_sl; int i; - DPRINTF(E_DBG, L_MDNS, "Adding mDNS service %s/%s\n", name, type); - ge = calloc(1, sizeof(struct mdns_group_entry)); if (!ge) { @@ -817,6 +895,7 @@ mdns_register(char *name, char *type, int port, char **txt) return -1; } + ge->publish = MDNS_PUBLISH_SERVICE; ge->name = strdup(name); ge->type = strdup(type); ge->port = port; @@ -837,14 +916,30 @@ mdns_register(char *name, char *type, int port, char **txt) ge->next = group_entries; group_entries = ge; - if (mdns_group) + create_all_group_entries(); // TODO why is this required? + + return 0; +} + +int +mdns_cname(char *name) +{ + struct mdns_group_entry *ge; + + ge = calloc(1, sizeof(struct mdns_group_entry)); + if (!ge) { - DPRINTF(E_DBG, L_MDNS, "Resetting mDNS group\n"); - avahi_entry_group_reset(mdns_group); + DPRINTF(E_LOG, L_MDNS, "Out of memory for mDNS CNAME\n"); + return -1; } - DPRINTF(E_DBG, L_MDNS, "Creating service group\n"); - _create_services(); + ge->publish = MDNS_PUBLISH_CNAME; + ge->name = strdup(name); + + ge->next = group_entries; + group_entries = ge; + + create_all_group_entries(); return 0; } diff --git a/src/spotify.c b/src/spotify.c index 41f6b1d6..725ace6f 100644 --- a/src/spotify.c +++ b/src/spotify.c @@ -1,5 +1,5 @@ /* - * Copyright (C) 2014 Espen Jürgensen + * Copyright (C) 2016 Espen Jürgensen * * Stiched together from libspotify examples * @@ -44,18 +44,51 @@ #include #include +#ifdef HAVE_JSON_C_OLD +# include +#else +# include +#endif + #include "spotify.h" #include "logger.h" +#include "misc.h" +#include "http.h" #include "conffile.h" #include "filescanner.h" #include "cache.h" #include "commands.h" +/* TODO for the web api: + * - UI should be prettier + * - map "added_at" to time_added + * - what to do about the lack of push? + * - use the web api more, implement proper init +*/ -/* How long to wait for audio (in sec) before giving up */ +/* A few words on our reloading sequence of saved tracks + * + * 1. libspotify will not tell us about the user's saved tracks when loading + * so we keep track of them with the special playlist spotify:savedtracks. + * 2. spotify_login will copy all paths in spotify:savedtracks to a temporary + * spotify_reload_list before all Spotify items in the database get purged. + * 3. when the connection to Spotify is established after login, we register + * all the paths with libspotify, and we also add them back to the + * spotify:savedtracks playlist - however, that's just for the + * playlistsitems table. Adding the items to the files table is done when + * libspotify calls back with metadata - see spotify_pending_process(). + * 4. if the user reloads saved tracks, we first clear all items in the + * playlist, then add those back that are returned from the web api, and + * then use our normal cleanup of stray files to tidy db and cache. + */ + +// How long to wait for audio (in sec) before giving up #define SPOTIFY_TIMEOUT 20 -/* How long to wait for artwork (in sec) before giving up */ +// How long to wait for artwork (in sec) before giving up #define SPOTIFY_ARTWORK_TIMEOUT 3 +// An upper limit on sequential requests to Spotify's web api +// - each request will return 50 objects (tracks) +#define SPOTIFY_WEB_REQUESTS_MAX 20 /* --- Types --- */ typedef struct audio_fifo_data @@ -103,6 +136,18 @@ struct artwork_get_param int is_loaded; }; +struct pending_metadata +{ + sp_link *link; + sp_track *track; + struct pending_metadata *next; +}; + +struct reload_list +{ + char *uri; + struct reload_list *next; +}; /* --- Globals --- */ // Spotify thread @@ -119,14 +164,20 @@ static struct event *g_notifyev; static struct commands_base *cmdbase; -// The global session handle +// The session handle static sp_session *g_sess; -// The global library handle +// The library handle static void *g_libhandle; -// The global state telling us what the thread is currently doing +// The state telling us what the thread is currently doing static enum spotify_state g_state; -// The global base playlist id (parent of all Spotify playlists in the db) -static int g_base_plid; +// The base playlist id for all Spotify playlists in the db +static int spotify_base_plid; +// The base playlist id for Spotify saved tracks in the db +static int spotify_saved_plid; +// Linked list of tracks where we are waiting for metadata +static struct pending_metadata *spotify_pending_metadata; +// Linked list of saved tracks which we want to reload at startup +static struct reload_list *spotify_reload_list; // Audio fifo static audio_fifo_t *g_audio_fifo; @@ -159,6 +210,15 @@ const uint8_t g_appkey[] = { 0x09, }; +// Endpoints and credentials for the web api +static char *spotify_access_token; +static char *spotify_refresh_token; +static const char *spotify_client_id = "0e684a5422384114a8ae7ac020f01789"; +static const char *spotify_client_secret = "232af95f39014c9ba218285a5c11a239"; +static const char *spotify_auth_uri = "https://accounts.spotify.com/authorize"; +static const char *spotify_token_uri = "https://accounts.spotify.com/api/token"; +static const char *spotify_tracks_uri = "https://api.spotify.com/v1/me/tracks?limit=50"; + // This section defines and assigns function pointers to the libspotify functions // The arguments and return values must be in sync with the spotify api // Please scroll through the ugliness which follows @@ -382,6 +442,111 @@ fptr_assign_all() // End of ugly part +/* ------------------------------- MISC HELPERS ---------------------------- */ + +static int +spotify_file_read(char *path, char **username, char **password) +{ + FILE *fp; + char *u; + char *p; + char buf[256]; + int len; + + fp = fopen(path, "rb"); + if (!fp) + { + DPRINTF(E_LOG, L_SPOTIFY, "Could not open Spotify credentials file %s: %s\n", path, strerror(errno)); + return -1; + } + + u = fgets(buf, sizeof(buf), fp); + if (!u) + { + DPRINTF(E_LOG, L_SPOTIFY, "Empty Spotify credentials file %s\n", path); + + fclose(fp); + return -1; + } + + len = strlen(u); + if (buf[len - 1] != '\n') + { + DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify credentials file %s: username name too long or missing password\n", path); + + fclose(fp); + return -1; + } + + while (len) + { + if ((buf[len - 1] == '\r') || (buf[len - 1] == '\n')) + { + buf[len - 1] = '\0'; + len--; + } + else + break; + } + + if (!len) + { + DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify credentials file %s: empty line where username expected\n", path); + + fclose(fp); + return -1; + } + + u = strdup(buf); + if (!u) + { + DPRINTF(E_LOG, L_SPOTIFY, "Out of memory for username while reading %s\n", path); + + fclose(fp); + return -1; + } + + p = fgets(buf, sizeof(buf), fp); + fclose(fp); + if (!p) + { + DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify credentials file %s: no password\n", path); + + free(u); + return -1; + } + + len = strlen(p); + + while (len) + { + if ((buf[len - 1] == '\r') || (buf[len - 1] == '\n')) + { + buf[len - 1] = '\0'; + len--; + } + else + break; + } + + p = strdup(buf); + if (!p) + { + DPRINTF(E_LOG, L_SPOTIFY, "Out of memory for password while reading %s\n", path); + + free(u); + return -1; + } + + DPRINTF(E_LOG, L_SPOTIFY, "Spotify credentials file OK, logging in with username %s\n", u); + + *username = u; + *password = p; + + return 0; +} + + /* -------------------------- PLAYLIST HELPERS ------------------------- */ /* Should only be called from within the spotify thread */ @@ -422,7 +587,7 @@ spotify_metadata_get(sp_track *track, struct media_file_info *mfi, const char *p compilation = ((albumtype == SP_ALBUMTYPE_COMPILATION) || artist_override); - if (album_override) + if (album_override && pltitle) albumname = strdup(pltitle); else albumname = strdup(fptr_sp_album_name(album)); @@ -500,11 +665,14 @@ spotify_track_save(int plid, sp_track *track, const char *pltitle, int time_adde fptr_sp_link_release(link); /* Add to playlistitems table */ - ret = db_pl_add_item_bypath(plid, url); - if (ret < 0) + if (plid) { - DPRINTF(E_LOG, L_SPOTIFY, "Could not save playlist item: '%s'\n", url); - return -1; + ret = db_pl_add_item_bypath(plid, url); + if (ret < 0) + { + DPRINTF(E_LOG, L_SPOTIFY, "Could not save playlist item: '%s'\n", url); + return -1; + } } memset(&mfi, 0, sizeof(struct media_file_info)); @@ -546,6 +714,8 @@ spotify_track_save(int plid, sp_track *track, const char *pltitle, int time_adde return -1; } +// DPRINTF(E_DBG, L_SPOTIFY, "Saving track '%s': '%s' by %s (%s)\n", url, mfi.title, mfi.artist, mfi.album); + filescanner_process_media(url, time(NULL), 0, F_SCAN_TYPE_SPOTIFY, &mfi, dir_id); free_mfi(&mfi, 1); @@ -554,7 +724,7 @@ spotify_track_save(int plid, sp_track *track, const char *pltitle, int time_adde } static int -spotify_playlist_cleanupfiles() +spotify_cleanup_files(void) { struct query_params qp; char *path; @@ -570,7 +740,6 @@ spotify_playlist_cleanupfiles() if (ret < 0) { db_query_end(&qp); - return -1; } @@ -629,7 +798,7 @@ spotify_playlist_save(sp_playlist *pl) DPRINTF(E_LOG, L_SPOTIFY, "Saving playlist (%d tracks): '%s'\n", num_tracks, name); - /* Save playlist (playlists table) */ + // Save playlist (playlists table) link = fptr_sp_link_create_from_playlist(pl); if (!link) { @@ -703,7 +872,7 @@ spotify_playlist_save(sp_playlist *pl) pli->title = strdup(name); pli->path = strdup(url); pli->virtual_path = strdup(virtual_path); - pli->parent_id = g_base_plid; + pli->parent_id = spotify_base_plid; pli->directory_id = DIR_SPOTIFY; ret = db_pl_add(pli, &plid); @@ -718,7 +887,7 @@ spotify_playlist_save(sp_playlist *pl) free_pli(pli, 0); - /* Save tracks and playlistitems (files and playlistitems table) */ + // Save tracks and playlistitems (files and playlistitems table) db_transaction_begin(); for (i = 0; i < num_tracks; i++) { @@ -739,12 +908,427 @@ spotify_playlist_save(sp_playlist *pl) } } - spotify_playlist_cleanupfiles(); + spotify_cleanup_files(); db_transaction_end(); return plid; } +// Registers a track with libspotify, which will make it start loading the track +// metadata. When that is done metadata_updated() is called (but we won't be +// told which track it was...). Note that this function will result in a ref +// count on the sp_link, which the caller must decrease with sp_link_release. +static enum command_state +spotify_uri_register(void *arg, int *retval) +{ + struct playlist_info pli; + struct pending_metadata *pm; + sp_link *link; + sp_track *track; + int ret; + + char *uri = arg; + + if (SP_CONNECTION_STATE_LOGGED_IN != fptr_sp_session_connectionstate(g_sess)) + { + DPRINTF(E_LOG, L_SPOTIFY, "Can't register music, not connected and logged in to Spotify\n"); + *retval = -1; + return COMMAND_END; + } + + // Must have playlist for these items, otherwise spotify_cleanup_files will delete them again + if (!spotify_saved_plid) + { + memset(&pli, 0, sizeof(struct playlist_info)); + pli.title = "Spotify Saved"; + pli.type = PL_PLAIN; + pli.path = "spotify:savedtracks"; + + ret = db_pl_add(&pli, &spotify_saved_plid); + if (ret < 0) + { + DPRINTF(E_LOG, L_SPOTIFY, "Error adding playlist for saved tracks\n"); + *retval = -1; + return COMMAND_END; + } + } + + ret = db_pl_add_item_bypath(spotify_saved_plid, uri); + if (ret < 0) + { + DPRINTF(E_LOG, L_SPOTIFY, "Could not add '%s' to spotify:savedtracks\n", uri); + *retval = -1; + return COMMAND_END; + } + + link = fptr_sp_link_create_from_string(uri); + if (!link) + { + DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify link: '%s'\n", uri); + *retval = -1; + return COMMAND_END; + } + + track = fptr_sp_link_as_track(link); + if (!track) + { + DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify track: '%s'\n", uri); + *retval = -1; + return COMMAND_END; + } + + // Maybe we already had the track + if (fptr_sp_track_is_loaded(track)) + { + db_file_ping_bymatch(uri, 0); + fptr_sp_link_release(link); + *retval = 0; + return COMMAND_END; + } + + pm = malloc(sizeof(struct pending_metadata)); + if (!pm) + { + DPRINTF(E_LOG, L_SPOTIFY, "Out of memory\n"); + *retval = -1; + return COMMAND_END; + } + + pm->link = link; + pm->track = track; + pm->next = spotify_pending_metadata; + spotify_pending_metadata = pm; + + *retval = 0; + return COMMAND_END; +} + +// TODO Maybe use the commands bh instead? +static enum command_state +spotify_pending_process(void *arg, int *retval) +{ + struct pending_metadata *pm; + int i; + + *retval = 0; + if (!spotify_pending_metadata) + return COMMAND_END; + + // Too early + i = 0; + for (pm = spotify_pending_metadata; pm; pm = pm->next) + { + i++; + + if (!fptr_sp_track_is_loaded(pm->track)) + return COMMAND_END; + } + + DPRINTF(E_DBG, L_SPOTIFY, "All %d tracks loaded, now saving\n", i); + + while ((pm = spotify_pending_metadata)) + { + spotify_track_save(0, pm->track, NULL, time(NULL)); + + // Not sure if we should release link here? We are done with it, but maybe + // libspotify will unload the track if we release, and we don't want that + //fptr_sp_link_release(pm->link); + + spotify_pending_metadata = pm->next; + free(pm); + } + + return COMMAND_END; +} + +static enum command_state +spotify_saved_pl_clear_items(void *arg, int *retval) +{ + if (spotify_saved_plid) + db_pl_clear_items(spotify_saved_plid); + + + *retval = 0; + + return COMMAND_END; +} + +static enum command_state +spotify_cleanup_wrapper(void *arg, int *retval) +{ + *retval = spotify_cleanup_files(); + + return COMMAND_END; +} + +/*--------------------- HELPERS FOR SPOTIFY WEB API -------------------------*/ +/* All the below is in the httpd thread */ + +static char * +jparse_str_from_obj(json_object *haystack, const char *key) +{ + json_object *needle; + + if (json_object_object_get_ex(haystack, key, &needle) && json_object_get_type(needle) == json_type_string) + return strdup(json_object_get_string(needle)); + else + return NULL; +} + +static char * +jparse_str_from_str(const char *s, const char *key) +{ + json_object *haystack; + char *val; + + haystack = json_tokener_parse(s); + if (!haystack) + { + DPRINTF(E_LOG, L_SPOTIFY, "JSON parser returned an error\n"); + return NULL; + } + + val = jparse_str_from_obj(haystack, key); + +#ifdef HAVE_JSON_C_OLD + json_object_put(haystack); +#else + if (json_object_put(haystack) != 1) + DPRINTF(E_LOG, L_SPOTIFY, "Memleak: JSON parser did not free object\n"); +#endif + + return val; +} + +// Will find all track Spotify uri's and register them with libspotify. +// Returns the number of tracks found in the json input. "total" will be the +// total reported by Spotify in the response, and "next" will be an allocated +// string with the url of the next page, as reported by Spotify +static int +jparse_and_register_tracks(int *total, char **next, const char *s) +{ + json_object *haystack; + json_object *needle; + json_object *items; + json_object *item; + json_object *track; + char *uri; + int ret; + int len; + int i; + + haystack = json_tokener_parse(s); + if (!haystack) + { + DPRINTF(E_LOG, L_SPOTIFY, "JSON parser returned an error\n"); + return -1; + } + + if (json_object_object_get_ex(haystack, "total", &needle) && json_object_get_type(needle) == json_type_int) + *total = json_object_get_int(needle); + else + *total = -1; + + *next = jparse_str_from_obj(haystack, "next"); + + if (! (json_object_object_get_ex(haystack, "items", &items) && json_object_get_type(items) == json_type_array) ) + { + DPRINTF(E_LOG, L_SPOTIFY, "No items in reply from Spotify. See:\n%s\n", s); + ret = -1; + goto out_free_json; + } + + len = json_object_array_length(items); + + DPRINTF(E_DBG, L_SPOTIFY, "Got %d saved tracks\n", len); + + for (i = 0; i < len; i++) + { + item = json_object_array_get_idx(items, i); + if (! (item && json_object_object_get_ex(item, "track", &track) + && (uri = jparse_str_from_obj(track, "uri")) )) + { + DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: Item %d did not have 'track'->'uri'\n", i); + len--; + continue; + } + + commands_exec_sync(cmdbase, spotify_uri_register, NULL, uri); + + free(uri); + } + + ret = len; + + out_free_json: +#ifdef HAVE_JSON_C_OLD + json_object_put(haystack); +#else + if (json_object_put(haystack) != 1) + DPRINTF(E_LOG, L_SPOTIFY, "Memleak: JSON parser did not free object\n"); +#endif + + return ret; +} + +static int +tokens_get(const char *code, const char *redirect_uri, const char **err) +{ + struct http_client_ctx ctx; + struct keyval kv; + char *param; + char *body; + int ret; + + memset(&kv, 0, sizeof(struct keyval)); + ret = ( (keyval_add(&kv, "grant_type", "authorization_code") == 0) && + (keyval_add(&kv, "code", code) == 0) && + (keyval_add(&kv, "client_id", spotify_client_id) == 0) && + (keyval_add(&kv, "client_secret", spotify_client_secret) == 0) && + (keyval_add(&kv, "redirect_uri", redirect_uri) == 0) ); + if (!ret) + { + *err = "Add parameters to keyval failed"; + ret = -1; + goto out_clear_kv; + } + + param = http_form_urlencode(&kv); + if (!param) + { + *err = "http_form_uriencode() failed"; + ret = -1; + goto out_clear_kv; + } + + memset(&ctx, 0, sizeof(struct http_client_ctx)); + ctx.url = (char *)spotify_token_uri; + ctx.output_body = param; + ctx.input_body = evbuffer_new(); + + ret = http_client_request(&ctx); + if (ret < 0) + { + *err = "Did not get a reply from Spotify"; + goto out_free_input_body; + } + + // 0-terminate for safety + evbuffer_add(ctx.input_body, "", 1); + + body = (char *)evbuffer_pullup(ctx.input_body, -1); + if (!body || (strlen(body) == 0)) + { + *err = "The reply from Spotify is empty or invalid"; + ret = -1; + goto out_free_input_body; + } + + spotify_access_token = jparse_str_from_str(body, "access_token"); + spotify_refresh_token = jparse_str_from_str(body, "refresh_token"); + + if (!spotify_access_token || !spotify_refresh_token) + { + DPRINTF(E_LOG, L_SPOTIFY, "Could not find token in reply: %s\n", body); + + *err = "Could not find token in Spotify reply (see log)"; + ret = -1; + goto out_free_input_body; + } + + ret = 0; + + out_free_input_body: + evbuffer_free(ctx.input_body); + free(param); + out_clear_kv: + keyval_clear(&kv); + + return ret; +} + +static int +saved_tracks_get(int *total, const char **err, const char *uri) +{ + struct http_client_ctx ctx; + struct keyval kv; + char bearer_token[1024]; + char *body; + char *next; + int ret; + int i; + + *total = -1; + + snprintf(bearer_token, sizeof(bearer_token), "Bearer %s", spotify_access_token); + + memset(&kv, 0, sizeof(struct keyval)); + if (keyval_add(&kv, "Authorization", bearer_token) < 0) + { + *err = "Add bearer_token to keyval failed"; + return -1; + } + + memset(&ctx, 0, sizeof(struct http_client_ctx)); + ctx.output_headers = &kv; + ctx.input_body = evbuffer_new(); + ctx.url = uri; + + next = NULL; + for (i = 0; i < SPOTIFY_WEB_REQUESTS_MAX; i++) + { + ret = http_client_request(&ctx); + if (ret < 0) + { + *err = "Request for saved tracks/albums failed"; + break; + } + + // 0-terminate for safety + evbuffer_add(ctx.input_body, "", 1); + + body = (char *)evbuffer_pullup(ctx.input_body, -1); + if (!body || (strlen(body) == 0)) + { + *err = "Request for saved tracks/albums failed, response was empty"; + ret = -1; + break; + } + + if (next) + free(next); + next = NULL; + + if (uri == spotify_tracks_uri) + ret = jparse_and_register_tracks(total, &next, body); + else + ret = -1; + + if (ret < 0) + { + *err = "Could not parse track/album response from Spotify"; + break; + } + + ret += 50 * i; // Equals total number of tracks/albums registered + + if (!next || (strncmp(next, "null", 4) == 0)) + break; + + ctx.url = next; + + evbuffer_drain(ctx.input_body, evbuffer_get_length(ctx.input_body)); + } + + if (next) + free(next); + + evbuffer_free(ctx.input_body); + keyval_clear(&kv); + + return ret; +} + /* -------------------------- PLAYLIST CALLBACKS ------------------------- */ /** @@ -855,7 +1439,8 @@ playlist_removed(sp_playlistcontainer *pc, sp_playlist *pl, int position, void * free_pli(pli, 0); db_spotify_pl_delete(plid); - spotify_playlist_cleanupfiles(); + + spotify_cleanup_files(); } /** @@ -952,7 +1537,7 @@ playback_setup(void *arg, int *retval) *retval = -1; return COMMAND_END; } - + err = fptr_sp_session_player_load(g_sess, track); if (SP_ERROR_OK != err) { @@ -1358,7 +1943,7 @@ logged_in(sp_session *sess, sp_error error) return; } - DPRINTF(E_LOG, L_SPOTIFY, "Login to Spotify succeeded. Reloading playlists.\n"); + DPRINTF(E_LOG, L_SPOTIFY, "Login to Spotify succeeded, reloading playlists\n"); db_directory_enable_bypath("/spotify:"); @@ -1373,7 +1958,7 @@ logged_in(sp_session *sess, sp_error error) pli.type = PL_FOLDER; pli.path = "spotify:playlistfolder"; - ret = db_pl_add(&pli, &g_base_plid); + ret = db_pl_add(&pli, &spotify_base_plid); if (ret < 0) { DPRINTF(E_LOG, L_SPOTIFY, "Error adding base playlist\n"); @@ -1381,7 +1966,7 @@ logged_in(sp_session *sess, sp_error error) } } else - g_base_plid = 0; + spotify_base_plid = 0; pc = fptr_sp_session_playlistcontainer(sess); @@ -1503,6 +2088,8 @@ notify_main_thread(sp_session *sess) static void metadata_updated(sp_session *session) { DPRINTF(E_DBG, L_SPOTIFY, "Session metadata updated\n"); + + commands_exec_async(cmdbase, spotify_pending_process, NULL); } /* Misc connection error callbacks */ @@ -1515,9 +2102,20 @@ static void play_token_lost(sp_session *sess) static void connectionstate_updated(sp_session *session) { + struct reload_list *reload; + int ret; + if (SP_CONNECTION_STATE_LOGGED_IN == fptr_sp_session_connectionstate(session)) { - DPRINTF(E_LOG, L_SPOTIFY, "Connection to Spotify (re)established\n"); + DPRINTF(E_LOG, L_SPOTIFY, "Connection to Spotify (re)established, reloading saved tracks\n"); + + while ((reload = spotify_reload_list)) + { + spotify_uri_register(reload->uri, &ret); + spotify_reload_list = reload->next; + free(reload->uri); + free(reload); + } } else if (g_state == SPOTIFY_STATE_PLAYING) { @@ -1571,6 +2169,42 @@ static sp_session_config spconfig = { /* ------------------------------- MAIN LOOP ------------------------------- */ /* Thread: spotify */ +static struct reload_list * +reload_list_create(int plid) +{ + struct query_params qp; + struct db_media_file_info dbmfi; + struct reload_list *head; + struct reload_list *reload; + int ret; + + memset(&qp, 0, sizeof(struct query_params)); + + qp.type = Q_PLITEMS; + qp.sort = S_NONE; + qp.id = plid; + + ret = db_query_start(&qp); + if (ret < 0) + { + db_query_end(&qp); + return NULL; + } + + head = NULL; + while (((ret = db_query_fetch_file(&qp, &dbmfi)) == 0) && (dbmfi.path)) + { + reload = malloc(sizeof(struct reload_list)); + reload->uri = strdup(dbmfi.path); + reload->next = head; + head = reload; + } + + db_query_end(&qp); + + return head; +} + static void * spotify(void *arg) { @@ -1761,119 +2395,102 @@ spotify_artwork_get(struct evbuffer *evbuf, char *path, int max_w, int max_h) return ret; } -static int -spotify_file_read(char *path, char **username, char **password) +/* Thread: httpd */ +void +spotify_oauth_interface(struct evbuffer *evbuf, const char *redirect_uri) { - FILE *fp; - char *u; - char *p; - char buf[256]; - int len; + struct keyval kv; + char *param; + int ret; - fp = fopen(path, "rb"); - if (!fp) + memset(&kv, 0, sizeof(struct keyval)); + ret = ( (keyval_add(&kv, "client_id", spotify_client_id) == 0) && + (keyval_add(&kv, "response_type", "code") == 0) && + (keyval_add(&kv, "redirect_uri", redirect_uri) == 0) && + (keyval_add(&kv, "scope", "playlist-read-private user-library-read") == 0) && + (keyval_add(&kv, "show_dialog", "false") == 0) ); + if (!ret) { - DPRINTF(E_LOG, L_SPOTIFY, "Could not open Spotify credentials file %s: %s\n", path, strerror(errno)); - return -1; + DPRINTF(E_LOG, L_SPOTIFY, "Cannot display Spotify oath interface (error adding parameters to keyval)\n"); + goto out_clear_kv; } - u = fgets(buf, sizeof(buf), fp); - if (!u) + param = http_form_urlencode(&kv); + if (!param) { - DPRINTF(E_LOG, L_SPOTIFY, "Empty Spotify credentials file %s\n", path); - - fclose(fp); - return -1; + DPRINTF(E_LOG, L_SPOTIFY, "Cannot display Spotify oath interface (http_form_uriencode() failed)\n"); + goto out_clear_kv; } - len = strlen(u); - if (buf[len - 1] != '\n') - { - DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify credentials file %s: username name too long or missing password\n", path); + evbuffer_add_printf(evbuf, "Click here to authorize forked-daapd with Spotify\n", spotify_auth_uri, param); - fclose(fp); - return -1; + free(param); + + out_clear_kv: + keyval_clear(&kv); +} + +/* Thread: httpd */ +void +spotify_oauth_callback(struct evbuffer *evbuf, struct evkeyvalq *param, const char *redirect_uri) +{ + const char *code; + const char *err; + int total; + int ret; + + code = evhttp_find_header(param, "code"); + if (!code) + { + evbuffer_add_printf(evbuf, "Error: Didn't receive a code from Spotify\n"); + return; } - while (len) + DPRINTF(E_DBG, L_SPOTIFY, "Received OAuth code: %s\n", code); + + evbuffer_add_printf(evbuf, "

Requesting access token from Spotify...\n"); + + ret = tokens_get(code, redirect_uri, &err); + if (ret < 0) { - if ((buf[len - 1] == '\r') || (buf[len - 1] == '\n')) - { - buf[len - 1] = '\0'; - len--; - } - else - break; + evbuffer_add_printf(evbuf, "failed

\n

Error: %s

\n", err); + return; } - if (!len) - { - DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify credentials file %s: empty line where username expected\n", path); + commands_exec_sync(cmdbase, spotify_saved_pl_clear_items, NULL, NULL); - fclose(fp); - return -1; + evbuffer_add_printf(evbuf, "ok

\n

Retrieving saved tracks...\n"); + + ret = saved_tracks_get(&total, &err, spotify_tracks_uri); + if (ret < 0) + { + evbuffer_add_printf(evbuf, "failed

\n

Error: %s

\n", err); + return; } - u = strdup(buf); - if (!u) - { - DPRINTF(E_LOG, L_SPOTIFY, "Out of memory for username while reading %s\n", path); + evbuffer_add_printf(evbuf, "ok, got %d out of %d tracks

\n", ret, total); - fclose(fp); - return -1; - } + evbuffer_add_printf(evbuf, "

Purging removed tracks/albums...\n"); - p = fgets(buf, sizeof(buf), fp); - fclose(fp); - if (!p) - { - DPRINTF(E_LOG, L_SPOTIFY, "Invalid Spotify credentials file %s: no password\n", path); + // TODO release links to the items we are going to clean up - free(u); - return -1; - } + commands_exec_sync(cmdbase, spotify_cleanup_wrapper, NULL, NULL); - len = strlen(p); + evbuffer_add_printf(evbuf, "ok, all done

\n"); - while (len) - { - if ((buf[len - 1] == '\r') || (buf[len - 1] == '\n')) - { - buf[len - 1] = '\0'; - len--; - } - else - break; - } - - p = strdup(buf); - if (!p) - { - DPRINTF(E_LOG, L_SPOTIFY, "Out of memory for password while reading %s\n", path); - - free(u); - return -1; - } - - DPRINTF(E_LOG, L_SPOTIFY, "Spotify credentials file OK, logging in with username %s\n", u); - - *username = u; - *password = p; - - return 0; + return; } /* Thread: filescanner */ void spotify_login(char *path) { + struct playlist_info *pli; sp_error err; char *username; char *password; int ret; - db_spotify_purge(); - if (!g_sess) { if (!g_libhandle) @@ -1905,8 +2522,12 @@ spotify_login(char *path) } DPRINTF(E_INFO, L_SPOTIFY, "Logging into Spotify\n"); + if (path) { + db_spotify_purge(); + spotify_saved_plid = 0; + ret = spotify_file_read(path, &username, &password); if (ret < 0) return; @@ -1917,6 +2538,16 @@ spotify_login(char *path) } else { + pli = db_pl_fetch_bypath("spotify:savedtracks"); + if (pli) + { + spotify_reload_list = reload_list_create(pli->id); + free_pli(pli, 0); + } + + db_spotify_purge(); + spotify_saved_plid = 0; + err = fptr_sp_session_relogin(g_sess); } diff --git a/src/spotify.h b/src/spotify.h index 495b6577..7b4b6f9d 100644 --- a/src/spotify.h +++ b/src/spotify.h @@ -4,6 +4,7 @@ #include #include +#include int spotify_playback_setup(const char *path); @@ -32,6 +33,12 @@ spotify_audio_get(struct evbuffer *evbuf, int wanted); int spotify_artwork_get(struct evbuffer *evbuf, char *path, int max_w, int max_h); +void +spotify_oauth_interface(struct evbuffer *evbuf, const char *redirect_uri); + +void +spotify_oauth_callback(struct evbuffer *evbuf, struct evkeyvalq *param, const char *redirect_uri); + void spotify_login(char *path);