mirror of
https://github.com/owntone/owntone-server.git
synced 2025-01-01 01:53:23 -05:00
153eb40b6d
The artwork db field should only be used to save media file artwork metadata, which mostly means whether it has embedded artwork or not. It should not be used to save where we found artwork for the file, since that is not static. So this commit removes for instance ARTWORK_OWN, ARTWORK_DIR etc., which we weren't using anyway.
2022 lines
50 KiB
C
2022 lines
50 KiB
C
/*
|
|
* Copyright (C) 2016 Espen Jürgensen <espenjurgensen@gmail.com>
|
|
* Copyright (C) 2016 Christian Meffert <christian.meffert@googlemail.com>
|
|
*
|
|
* 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
|
|
*/
|
|
|
|
#include "spotify_webapi.h"
|
|
|
|
#include <event2/event.h>
|
|
#include <json.h>
|
|
#include <stddef.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include "artwork.h"
|
|
#include "cache.h"
|
|
#include "conffile.h"
|
|
#include "db.h"
|
|
#include "http.h"
|
|
#include "library.h"
|
|
#include "listener.h"
|
|
#include "logger.h"
|
|
#include "misc_json.h"
|
|
#include "spotify.h"
|
|
|
|
|
|
struct spotify_album
|
|
{
|
|
const char *added_at;
|
|
time_t mtime;
|
|
|
|
const char *album_type;
|
|
bool is_compilation;
|
|
const char *artist;
|
|
const char *genre;
|
|
const char *id;
|
|
const char *label;
|
|
const char *name;
|
|
const char *release_date;
|
|
const char *release_date_precision;
|
|
int release_year;
|
|
const char *uri;
|
|
const char *artwork_url;
|
|
};
|
|
|
|
struct spotify_track
|
|
{
|
|
const char *added_at;
|
|
time_t mtime;
|
|
|
|
const char *album;
|
|
const char *album_artist;
|
|
const char *artist;
|
|
int disc_number;
|
|
const char *album_type;
|
|
bool is_compilation;
|
|
int duration_ms;
|
|
const char *id;
|
|
const char *name;
|
|
int track_number;
|
|
const char *uri;
|
|
const char *artwork_url;
|
|
|
|
bool is_playable;
|
|
const char *restrictions;
|
|
const char *linked_from_uri;
|
|
};
|
|
|
|
struct spotify_playlist
|
|
{
|
|
const char *id;
|
|
const char *name;
|
|
const char *owner;
|
|
const char *uri;
|
|
|
|
const char *href;
|
|
|
|
const char *tracks_href;
|
|
int tracks_count;
|
|
};
|
|
|
|
// Credentials for the web api
|
|
static char *spotify_access_token;
|
|
static char *spotify_refresh_token;
|
|
static char *spotify_granted_scope;
|
|
static char *spotify_user_country;
|
|
static char *spotify_user;
|
|
|
|
static int32_t expires_in = 3600;
|
|
static time_t token_requested = 0;
|
|
|
|
// Mutex to avoid conflicting requests for access tokens and protects accessing the credentials from different threads
|
|
static pthread_mutex_t token_lck;
|
|
|
|
|
|
// 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;
|
|
|
|
// Flag to avoid triggering playlist change events while the (re)scan is running
|
|
static bool scanning;
|
|
|
|
|
|
// Endpoints and credentials for the web api
|
|
static const char *spotify_client_id = "0e684a5422384114a8ae7ac020f01789";
|
|
static const char *spotify_client_secret = "232af95f39014c9ba218285a5c11a239";
|
|
static const char *spotify_scope = "playlist-read-private playlist-read-collaborative user-library-read user-read-private";
|
|
|
|
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_playlist_uri = "https://api.spotify.com/v1/playlists/%s";
|
|
static const char *spotify_track_uri = "https://api.spotify.com/v1/tracks/%s";
|
|
static const char *spotify_me_uri = "https://api.spotify.com/v1/me";
|
|
static const char *spotify_albums_uri = "https://api.spotify.com/v1/me/albums?limit=50";
|
|
static const char *spotify_album_uri = "https://api.spotify.com/v1/albums/%s";
|
|
static const char *spotify_album_tracks_uri = "https://api.spotify.com/v1/albums/%s/tracks";
|
|
static const char *spotify_playlists_uri = "https://api.spotify.com/v1/me/playlists?limit=50";
|
|
static const char *spotify_playlist_tracks_uri = "https://api.spotify.com/v1/playlists/%s/tracks";
|
|
static const char *spotify_artist_albums_uri = "https://api.spotify.com/v1/artists/%s/albums?include_groups=album,single";
|
|
|
|
|
|
|
|
static void
|
|
free_http_client_ctx(struct http_client_ctx *ctx)
|
|
{
|
|
if (!ctx)
|
|
return;
|
|
|
|
if (ctx->input_body)
|
|
evbuffer_free(ctx->input_body);
|
|
if (ctx->output_headers)
|
|
{
|
|
keyval_clear(ctx->output_headers);
|
|
free(ctx->output_headers);
|
|
}
|
|
free(ctx);
|
|
}
|
|
|
|
static bool
|
|
token_valid(void)
|
|
{
|
|
return spotify_access_token != NULL;
|
|
}
|
|
|
|
static int
|
|
request_access_tokens(struct keyval *kv, const char **err)
|
|
{
|
|
struct http_client_ctx ctx;
|
|
char *param;
|
|
char *body;
|
|
json_object *haystack;
|
|
const char *tmp;
|
|
int ret;
|
|
|
|
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;
|
|
}
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Token reply: %s\n", body);
|
|
|
|
haystack = json_tokener_parse(body);
|
|
if (!haystack)
|
|
{
|
|
*err = "JSON parser returned an error";
|
|
ret = -1;
|
|
goto out_free_input_body;
|
|
}
|
|
|
|
free(spotify_access_token);
|
|
spotify_access_token = NULL;
|
|
|
|
tmp = jparse_str_from_obj(haystack, "access_token");
|
|
if (tmp)
|
|
spotify_access_token = strdup(tmp);
|
|
|
|
tmp = jparse_str_from_obj(haystack, "refresh_token");
|
|
if (tmp)
|
|
{
|
|
free(spotify_refresh_token);
|
|
spotify_refresh_token = strdup(tmp);
|
|
}
|
|
|
|
tmp = jparse_str_from_obj(haystack, "scope");
|
|
if (tmp)
|
|
{
|
|
free(spotify_granted_scope);
|
|
spotify_granted_scope = strdup(tmp);
|
|
}
|
|
|
|
expires_in = jparse_int_from_obj(haystack, "expires_in");
|
|
if (expires_in == 0)
|
|
expires_in = 3600;
|
|
|
|
jparse_free(haystack);
|
|
|
|
if (!spotify_access_token)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Could not find access token in reply: %s\n", body);
|
|
|
|
*err = "Could not find access token in Spotify reply (see log)";
|
|
ret = -1;
|
|
goto out_free_input_body;
|
|
}
|
|
|
|
token_requested = time(NULL);
|
|
|
|
if (spotify_refresh_token)
|
|
db_admin_set(DB_ADMIN_SPOTIFY_REFRESH_TOKEN, spotify_refresh_token);
|
|
|
|
ret = 0;
|
|
|
|
out_free_input_body:
|
|
evbuffer_free(ctx.input_body);
|
|
free(param);
|
|
out_clear_kv:
|
|
|
|
return ret;
|
|
}
|
|
|
|
/*
|
|
* Request the api endpoint at 'href' and retuns the response body as
|
|
* an allocated JSON object (must be freed by the caller) or NULL.
|
|
*
|
|
* @param href The spotify endpoint uri
|
|
* @return Response as JSON object or NULL
|
|
*/
|
|
static json_object *
|
|
request_endpoint(const char *uri)
|
|
{
|
|
struct http_client_ctx *ctx;
|
|
char bearer_token[1024];
|
|
char *response_body;
|
|
json_object *json_response = NULL;
|
|
int ret;
|
|
|
|
ctx = calloc(1, sizeof(struct http_client_ctx));
|
|
ctx->output_headers = calloc(1, sizeof(struct keyval));
|
|
ctx->input_body = evbuffer_new();
|
|
ctx->url = uri;
|
|
|
|
snprintf(bearer_token, sizeof(bearer_token), "Bearer %s", spotify_access_token);
|
|
if (keyval_add(ctx->output_headers, "Authorization", bearer_token) < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Add bearer_token to keyval failed for request '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Request Spotify API endpoint: '%s')\n", uri);
|
|
|
|
ret = http_client_request(ctx);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Request for '%s' failed\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
// 0-terminate for safety
|
|
evbuffer_add(ctx->input_body, "", 1);
|
|
|
|
response_body = (char *) evbuffer_pullup(ctx->input_body, -1);
|
|
if (!response_body || (strlen(response_body) == 0))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Request for '%s' failed, response was empty\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
// DPRINTF(E_DBG, L_SPOTIFY, "Wep api response for '%s'\n%s\n", uri, response_body);
|
|
|
|
json_response = json_tokener_parse(response_body);
|
|
if (!json_response)
|
|
DPRINTF(E_LOG, L_SPOTIFY, "JSON parser returned an error for '%s'\n", uri);
|
|
else
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Spotify API endpoint request: '%s'\n", uri);
|
|
|
|
out:
|
|
free_http_client_ctx(ctx);
|
|
|
|
return json_response;
|
|
}
|
|
|
|
/*
|
|
* Request user information
|
|
*
|
|
* API endpoint: https://api.spotify.com/v1/me
|
|
*/
|
|
static int
|
|
request_user_info(void)
|
|
{
|
|
json_object *response;
|
|
|
|
free(spotify_user_country);
|
|
spotify_user_country = NULL;
|
|
free(spotify_user);
|
|
spotify_user = NULL;
|
|
|
|
response = request_endpoint(spotify_me_uri);
|
|
|
|
if (response)
|
|
{
|
|
spotify_user = safe_strdup(jparse_str_from_obj(response, "id"));
|
|
spotify_user_country = safe_strdup(jparse_str_from_obj(response, "country"));
|
|
|
|
jparse_free(response);
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "User '%s', country '%s'\n", spotify_user, spotify_user_country);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* Called from the oauth callback to get a new access and refresh token
|
|
*
|
|
* @return 0 on success, -1 on failure
|
|
*/
|
|
static int
|
|
token_get(const char *code, const char *redirect_uri, const char **err)
|
|
{
|
|
struct keyval kv;
|
|
int ret;
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_lock(&token_lck));
|
|
|
|
*err = "";
|
|
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;
|
|
}
|
|
else
|
|
ret = request_access_tokens(&kv, err);
|
|
|
|
keyval_clear(&kv);
|
|
|
|
if (ret == 0)
|
|
request_user_info();
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_unlock(&token_lck));
|
|
|
|
return ret;
|
|
}
|
|
|
|
/*
|
|
* Get a new access token for the stored refresh token (user already granted
|
|
* access to the web api)
|
|
*
|
|
* First checks if the current access token is still valid and only requests
|
|
* a new token if not.
|
|
*
|
|
* @return 0 on success, -1 on failure
|
|
*/
|
|
static int
|
|
token_refresh(void)
|
|
{
|
|
struct keyval kv;
|
|
char *refresh_token = NULL;
|
|
const char *err;
|
|
int ret;
|
|
|
|
memset(&kv, 0, sizeof(struct keyval));
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_lock(&token_lck));
|
|
|
|
if (token_requested && difftime(time(NULL), token_requested) < expires_in)
|
|
{
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Spotify token still valid\n");
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_unlock(&token_lck));
|
|
return 0;
|
|
}
|
|
|
|
ret = db_admin_get(&refresh_token, DB_ADMIN_SPOTIFY_REFRESH_TOKEN);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "No spotify refresh token found\n");
|
|
goto error;
|
|
}
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Spotify refresh-token: '%s'\n", refresh_token);
|
|
|
|
ret = ( (keyval_add(&kv, "grant_type", "refresh_token") == 0) &&
|
|
(keyval_add(&kv, "client_id", spotify_client_id) == 0) &&
|
|
(keyval_add(&kv, "client_secret", spotify_client_secret) == 0) &&
|
|
(keyval_add(&kv, "refresh_token", refresh_token) == 0) );
|
|
if (!ret)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Add parameters to keyval failed");
|
|
goto error;
|
|
}
|
|
|
|
ret = request_access_tokens(&kv, &err);
|
|
|
|
if (ret == 0)
|
|
request_user_info();
|
|
|
|
free(refresh_token);
|
|
keyval_clear(&kv);
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_unlock(&token_lck));
|
|
|
|
return ret;
|
|
|
|
error:
|
|
free(refresh_token);
|
|
keyval_clear(&kv);
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_unlock(&token_lck));
|
|
|
|
return -1;
|
|
}
|
|
|
|
/*
|
|
* Request the api endpoint at 'href' and retuns the response body as
|
|
* an allocated JSON object (must be freed by the caller) or NULL.
|
|
*
|
|
* Before making the request, the validity of the current access token
|
|
* is checked and if necessary a token refresh request is issued before
|
|
* requesting the given endpoint.
|
|
*
|
|
* @param href The spotify endpoint uri
|
|
* @return Response as JSON object or NULL
|
|
*/
|
|
static json_object *
|
|
request_endpoint_with_token_refresh(const char *href)
|
|
{
|
|
if (0 > token_refresh())
|
|
{
|
|
return NULL;
|
|
}
|
|
|
|
return request_endpoint(href);
|
|
}
|
|
|
|
typedef int (*paging_request_cb)(void *arg);
|
|
typedef int (*paging_item_cb)(json_object *item, int index, int total, void *arg);
|
|
|
|
/*
|
|
* Request the spotify endpoint at 'href'
|
|
*
|
|
* The endpoint must return a "paging object" e. g.:
|
|
*
|
|
* {
|
|
* "items": [ item1, item2, ... ],
|
|
* "limit": 50,
|
|
* "next": "{uri for the next set of items}",
|
|
* "offset": 0,
|
|
* "total": {total number of items},
|
|
* }
|
|
*
|
|
* The given callback is invoked for every item in the "items" array.
|
|
* If "next" is set in the response, after processing all items, the next uri
|
|
* is requested and the callback is invoked for every item of this request.
|
|
* The function returns after all items are processed and there is no "next"
|
|
* request.
|
|
*
|
|
* @param endpoint_uri The endpont uri
|
|
* @param item_cb The callback function invoked for every item
|
|
* @param pre_request_cb Callback function invoked before each request (optional)
|
|
* @param post_request_cb Callback function invoked after each request (optional)
|
|
* @param with_market If TRUE appends the user country as market to the request (applies track relinking)
|
|
* @param arg User data passed to each callback
|
|
* @return 0 on success, -1 on failure
|
|
*/
|
|
static int
|
|
request_pagingobject_endpoint(const char *href, paging_item_cb item_cb, paging_request_cb pre_request_cb, paging_request_cb post_request_cb, bool with_market, void *arg)
|
|
{
|
|
char *next_href;
|
|
json_object *response;
|
|
json_object *items;
|
|
json_object *item;
|
|
int count;
|
|
int i;
|
|
int offset;
|
|
int total;
|
|
int ret;
|
|
|
|
if (!with_market || !spotify_user_country)
|
|
{
|
|
next_href = safe_strdup(href);
|
|
}
|
|
else
|
|
{
|
|
if (strchr(href, '?'))
|
|
next_href = safe_asprintf("%s&market=%s", href, spotify_user_country);
|
|
else
|
|
next_href = safe_asprintf("%s?market=%s", href, spotify_user_country);
|
|
}
|
|
|
|
while (next_href)
|
|
{
|
|
if (pre_request_cb)
|
|
pre_request_cb(arg);
|
|
|
|
response = request_endpoint_with_token_refresh(next_href);
|
|
|
|
if (!response)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: no response for paging endpoint (API endpoint: '%s')\n", next_href);
|
|
|
|
if (post_request_cb)
|
|
post_request_cb(arg);
|
|
|
|
free(next_href);
|
|
return -1;
|
|
}
|
|
|
|
free(next_href);
|
|
next_href = safe_strdup(jparse_str_from_obj(response, "next"));
|
|
|
|
offset = jparse_int_from_obj(response, "offset");
|
|
total = jparse_int_from_obj(response, "total");
|
|
|
|
if (jparse_array_from_obj(response, "items", &items) == 0)
|
|
{
|
|
count = json_object_array_length(items);
|
|
for (i = 0; i < count; i++)
|
|
{
|
|
item = json_object_array_get_idx(items, i);
|
|
if (!item)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: no item at index %d in '%s' (API endpoint: '%s')\n",
|
|
i, json_object_to_json_string(items), href);
|
|
continue;
|
|
}
|
|
|
|
ret = item_cb(item, (i + offset), total, arg);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: error processing item at index %d '%s' (API endpoint: '%s')\n",
|
|
i, json_object_to_json_string(item), href);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (post_request_cb)
|
|
post_request_cb(arg);
|
|
|
|
jparse_free(response);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
static const char *
|
|
get_album_image(json_object *jsonalbum, int max_w)
|
|
{
|
|
json_object *jsonimages;
|
|
json_object *jsonimage;
|
|
int image_count;
|
|
int index;
|
|
const char *artwork_url;
|
|
|
|
artwork_url = NULL;
|
|
|
|
if (!json_object_object_get_ex(jsonalbum, "images", &jsonimages))
|
|
{
|
|
DPRINTF(E_DBG, L_SPOTIFY, "No images in for spotify album object found\n");
|
|
return NULL;
|
|
}
|
|
|
|
// Find first image that has a smaller width than the given max_w
|
|
// (this should avoid the need for resizing and improve performance at the cost of some quality loss)
|
|
// Note that Spotify returns the images ordered descending by width (widest image first)
|
|
// Special case is if no max width (max_w = 0) is given, the widest images will be used
|
|
image_count = json_object_array_length(jsonimages);
|
|
for (index = 0; index < image_count; index++)
|
|
{
|
|
jsonimage = json_object_array_get_idx(jsonimages, index);
|
|
if (jsonimage)
|
|
{
|
|
artwork_url = jparse_str_from_obj(jsonimage, "url");
|
|
|
|
if (max_w <= 0 || jparse_int_from_obj(jsonimage, "width") <= max_w)
|
|
{
|
|
// We have the first image that has a smaller width than the given max_w
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return artwork_url;
|
|
}
|
|
|
|
static void
|
|
parse_metadata_track(json_object *jsontrack, struct spotify_track *track, int max_w)
|
|
{
|
|
json_object *jsonalbum;
|
|
json_object *jsonartists;
|
|
json_object *needle;
|
|
|
|
memset(track, 0, sizeof(struct spotify_track));
|
|
|
|
if (json_object_object_get_ex(jsontrack, "album", &jsonalbum))
|
|
{
|
|
track->album = jparse_str_from_obj(jsonalbum, "name");
|
|
if (json_object_object_get_ex(jsonalbum, "artists", &jsonartists))
|
|
track->album_artist = jparse_str_from_array(jsonartists, 0, "name");
|
|
|
|
track->artwork_url = get_album_image(jsonalbum, max_w);
|
|
}
|
|
|
|
if (json_object_object_get_ex(jsontrack, "artists", &jsonartists))
|
|
track->artist = jparse_str_from_array(jsonartists, 0, "name");
|
|
|
|
track->disc_number = jparse_int_from_obj(jsontrack, "disc_number");
|
|
track->album_type = jparse_str_from_obj(jsonalbum, "album_type");
|
|
track->is_compilation = (track->album_type && 0 == strcmp(track->album_type, "compilation"));
|
|
track->duration_ms = jparse_int_from_obj(jsontrack, "duration_ms");
|
|
track->name = jparse_str_from_obj(jsontrack, "name");
|
|
track->track_number = jparse_int_from_obj(jsontrack, "track_number");
|
|
track->uri = jparse_str_from_obj(jsontrack, "uri");
|
|
track->id = jparse_str_from_obj(jsontrack, "id");
|
|
|
|
// "is_playable" is only returned for a request with a market parameter, default to true if it is not in the response
|
|
track->is_playable = true;
|
|
if (json_object_object_get_ex(jsontrack, "is_playable", NULL))
|
|
{
|
|
track->is_playable = jparse_bool_from_obj(jsontrack, "is_playable");
|
|
|
|
if (json_object_object_get_ex(jsontrack, "restrictions", &needle))
|
|
track->restrictions = json_object_to_json_string(needle);
|
|
|
|
if (json_object_object_get_ex(jsontrack, "linked_from", &needle))
|
|
track->linked_from_uri = jparse_str_from_obj(needle, "uri");
|
|
}
|
|
}
|
|
|
|
static int
|
|
get_year_from_date(const char *date)
|
|
{
|
|
char tmp[5];
|
|
uint32_t year = 0;
|
|
|
|
if (date && strlen(date) >= 4)
|
|
{
|
|
strncpy(tmp, date, sizeof(tmp));
|
|
tmp[4] = '\0';
|
|
safe_atou32(tmp, &year);
|
|
}
|
|
|
|
return year;
|
|
}
|
|
|
|
static void
|
|
parse_metadata_album(json_object *jsonalbum, struct spotify_album *album, int max_w)
|
|
{
|
|
json_object* jsonartists;
|
|
|
|
memset(album, 0, sizeof(struct spotify_album));
|
|
|
|
if (json_object_object_get_ex(jsonalbum, "artists", &jsonartists))
|
|
album->artist = jparse_str_from_array(jsonartists, 0, "name");
|
|
|
|
album->name = jparse_str_from_obj(jsonalbum, "name");
|
|
album->uri = jparse_str_from_obj(jsonalbum, "uri");
|
|
album->id = jparse_str_from_obj(jsonalbum, "id");
|
|
|
|
album->album_type = jparse_str_from_obj(jsonalbum, "album_type");
|
|
album->is_compilation = (album->album_type && 0 == strcmp(album->album_type, "compilation"));
|
|
|
|
album->label = jparse_str_from_obj(jsonalbum, "label");
|
|
|
|
album->release_date = jparse_str_from_obj(jsonalbum, "release_date");
|
|
album->release_date_precision = jparse_str_from_obj(jsonalbum, "release_date_precision");
|
|
album->release_year = get_year_from_date(album->release_date);
|
|
|
|
if (max_w > 0)
|
|
album->artwork_url = get_album_image(jsonalbum, max_w);
|
|
|
|
// TODO Genre is an array of strings ('genres'), but it is always empty (https://github.com/spotify/web-api/issues/157)
|
|
//album->genre = jparse_str_from_obj(jsonalbum, "genre");
|
|
}
|
|
|
|
static void
|
|
parse_metadata_playlist(json_object *jsonplaylist, struct spotify_playlist *playlist)
|
|
{
|
|
json_object *needle;
|
|
|
|
memset(playlist, 0, sizeof(struct spotify_playlist));
|
|
|
|
playlist->name = jparse_str_from_obj(jsonplaylist, "name");
|
|
playlist->uri = jparse_str_from_obj(jsonplaylist, "uri");
|
|
playlist->id = jparse_str_from_obj(jsonplaylist, "id");
|
|
playlist->href = jparse_str_from_obj(jsonplaylist, "href");
|
|
|
|
if (json_object_object_get_ex(jsonplaylist, "owner", &needle))
|
|
playlist->owner = jparse_str_from_obj(needle, "id");
|
|
|
|
if (json_object_object_get_ex(jsonplaylist, "tracks", &needle))
|
|
{
|
|
playlist->tracks_href = jparse_str_from_obj(needle, "href");
|
|
playlist->tracks_count = jparse_int_from_obj(needle, "total");
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Creates a new string for the playlist API endpoint for the given playist-uri.
|
|
* The returned string needs to be freed by the caller.
|
|
*
|
|
* @param uri Playlist uri (e. g. "spotify:user:username:playlist:59ZbFPES4DQwEjBpWHzrtC")
|
|
* @return Playlist endpoint uri (e. g. "https://api.spotify.com/v1/users/username/playlists/59ZbFPES4DQwEjBpWHzrtC")
|
|
*/
|
|
static int
|
|
get_id_from_uri(const char *uri, char **id)
|
|
{
|
|
char *tmp;
|
|
tmp = strrchr(uri, ':');
|
|
if (!tmp)
|
|
{
|
|
return -1;
|
|
}
|
|
tmp++;
|
|
|
|
*id = strdup(tmp);
|
|
|
|
return 0;
|
|
}
|
|
|
|
static char *
|
|
get_playlist_endpoint_uri(const char *uri)
|
|
{
|
|
char *endpoint_uri = NULL;
|
|
char *id = NULL;
|
|
int ret;
|
|
|
|
ret = get_id_from_uri(uri, &id);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error extracting owner and id from playlist uri '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
endpoint_uri = safe_asprintf(spotify_playlist_uri, id);
|
|
|
|
out:
|
|
free(id);
|
|
return endpoint_uri;
|
|
}
|
|
|
|
static char *
|
|
get_playlist_tracks_endpoint_uri(const char *uri)
|
|
{
|
|
char *endpoint_uri = NULL;
|
|
char *id = NULL;
|
|
int ret;
|
|
|
|
ret = get_id_from_uri(uri, &id);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error extracting owner and id from playlist uri '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
endpoint_uri = safe_asprintf(spotify_playlist_tracks_uri, id);
|
|
|
|
out:
|
|
free(id);
|
|
return endpoint_uri;
|
|
}
|
|
|
|
static char *
|
|
get_album_endpoint_uri(const char *uri)
|
|
{
|
|
char *endpoint_uri = NULL;
|
|
char *id = NULL;
|
|
int ret;
|
|
|
|
ret = get_id_from_uri(uri, &id);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error extracting id from uri '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
endpoint_uri = safe_asprintf(spotify_album_uri, id);
|
|
|
|
out:
|
|
free(id);
|
|
return endpoint_uri;
|
|
}
|
|
|
|
static char *
|
|
get_album_tracks_endpoint_uri(const char *uri)
|
|
{
|
|
char *endpoint_uri = NULL;
|
|
char *id = NULL;
|
|
int ret;
|
|
|
|
ret = get_id_from_uri(uri, &id);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error extracting id from uri '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
endpoint_uri = safe_asprintf(spotify_album_tracks_uri, id);
|
|
|
|
out:
|
|
free(id);
|
|
return endpoint_uri;
|
|
}
|
|
|
|
static char *
|
|
get_track_endpoint_uri(const char *uri)
|
|
{
|
|
char *endpoint_uri = NULL;
|
|
char *id = NULL;
|
|
int ret;
|
|
|
|
ret = get_id_from_uri(uri, &id);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error extracting id from track uri '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
endpoint_uri = safe_asprintf(spotify_track_uri, id);
|
|
|
|
out:
|
|
free(id);
|
|
return endpoint_uri;
|
|
}
|
|
|
|
static char *
|
|
get_artist_albums_endpoint_uri(const char *uri)
|
|
{
|
|
char *endpoint_uri = NULL;
|
|
char *id = NULL;
|
|
int ret;
|
|
|
|
ret = get_id_from_uri(uri, &id);
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error extracting id from uri '%s'\n", uri);
|
|
goto out;
|
|
}
|
|
|
|
endpoint_uri = safe_asprintf(spotify_artist_albums_uri, id);
|
|
|
|
out:
|
|
free(id);
|
|
return endpoint_uri;
|
|
}
|
|
|
|
static json_object *
|
|
request_track(const char *path)
|
|
{
|
|
char *endpoint_uri;
|
|
json_object *response;
|
|
|
|
endpoint_uri = get_track_endpoint_uri(path);
|
|
response = request_endpoint_with_token_refresh(endpoint_uri);
|
|
free(endpoint_uri);
|
|
|
|
return response;
|
|
}
|
|
|
|
/* Thread: httpd */
|
|
char *
|
|
spotifywebapi_oauth_uri_get(const char *redirect_uri)
|
|
{
|
|
struct keyval kv;
|
|
char *param;
|
|
char *uri;
|
|
int uri_len;
|
|
int ret;
|
|
|
|
uri = NULL;
|
|
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", spotify_scope) == 0) &&
|
|
(keyval_add(&kv, "show_dialog", "false") == 0) );
|
|
if (!ret)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Cannot display Spotify oath interface (error adding parameters to keyval)\n");
|
|
goto out_clear_kv;
|
|
}
|
|
|
|
param = http_form_urlencode(&kv);
|
|
if (param)
|
|
{
|
|
uri_len = strlen(spotify_auth_uri) + strlen(param) + 3;
|
|
uri = calloc(uri_len, sizeof(char));
|
|
snprintf(uri, uri_len, "%s/?%s", spotify_auth_uri, param);
|
|
|
|
free(param);
|
|
}
|
|
|
|
out_clear_kv:
|
|
keyval_clear(&kv);
|
|
|
|
return uri;
|
|
}
|
|
|
|
/* Thread: httpd */
|
|
int
|
|
spotifywebapi_oauth_callback(struct evkeyvalq *param, const char *redirect_uri, char **errmsg)
|
|
{
|
|
const char *code;
|
|
const char *err;
|
|
int ret;
|
|
|
|
*errmsg = NULL;
|
|
|
|
code = evhttp_find_header(param, "code");
|
|
if (!code)
|
|
{
|
|
*errmsg = safe_asprintf("Error: Didn't receive a code from Spotify");
|
|
return -1;
|
|
}
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Received OAuth code: %s\n", code);
|
|
|
|
ret = token_get(code, redirect_uri, &err);
|
|
if (ret < 0)
|
|
{
|
|
*errmsg = safe_asprintf("Error: %s", err);
|
|
return -1;
|
|
}
|
|
|
|
// Trigger scan after successful access to spotifywebapi
|
|
spotifywebapi_fullrescan();
|
|
|
|
listener_notify(LISTENER_SPOTIFY);
|
|
|
|
return 0;
|
|
}
|
|
|
|
static int
|
|
transaction_start(void *arg)
|
|
{
|
|
db_transaction_begin();
|
|
return 0;
|
|
}
|
|
|
|
static int
|
|
transaction_end(void *arg)
|
|
{
|
|
db_transaction_end();
|
|
return 0;
|
|
}
|
|
|
|
static void
|
|
map_track_to_queueitem(struct db_queue_item *item, const struct spotify_track *track, const struct spotify_album *album)
|
|
{
|
|
char virtual_path[PATH_MAX];
|
|
|
|
memset(item, 0, sizeof(struct db_queue_item));
|
|
|
|
item->file_id = DB_MEDIA_FILE_NON_PERSISTENT_ID;
|
|
item->title = safe_strdup(track->name);
|
|
item->artist = safe_strdup(track->artist);
|
|
|
|
if (album)
|
|
{
|
|
item->album_artist = safe_strdup(album->artist);
|
|
item->album = safe_strdup(album->name);
|
|
item->artwork_url = safe_strdup(album->artwork_url);
|
|
}
|
|
else
|
|
{
|
|
item->album_artist = safe_strdup(track->album_artist);
|
|
item->album = safe_strdup(track->album);
|
|
item->artwork_url = safe_strdup(track->artwork_url);
|
|
}
|
|
|
|
item->disc = track->disc_number;
|
|
item->song_length = track->duration_ms;
|
|
item->track = track->track_number;
|
|
|
|
item->data_kind = DATA_KIND_SPOTIFY;
|
|
item->media_kind = MEDIA_KIND_MUSIC;
|
|
|
|
item->path = safe_strdup(track->uri);
|
|
|
|
snprintf(virtual_path, PATH_MAX, "/%s", track->uri);
|
|
item->virtual_path = strdup(virtual_path);
|
|
}
|
|
|
|
static int
|
|
queue_add_track(const char *uri, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id)
|
|
{
|
|
json_object *response;
|
|
struct spotify_track track;
|
|
struct db_queue_item item;
|
|
struct db_queue_add_info queue_add_info;
|
|
int ret;
|
|
|
|
response = request_track(uri);
|
|
if (!response)
|
|
return -1;
|
|
|
|
parse_metadata_track(response, &track, ART_DEFAULT_WIDTH);
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Got track: '%s' (%s) \n", track.name, track.uri);
|
|
|
|
map_track_to_queueitem(&item, &track, NULL);
|
|
|
|
ret = db_queue_add_start(&queue_add_info, position);
|
|
if (ret == 0)
|
|
{
|
|
ret = db_queue_add_item(&queue_add_info, &item);
|
|
ret = db_queue_add_end(&queue_add_info, reshuffle, item_id, ret);
|
|
if (ret == 0)
|
|
{
|
|
if (count)
|
|
*count = queue_add_info.count;
|
|
if (new_item_id)
|
|
*new_item_id = queue_add_info.new_item_id;
|
|
}
|
|
}
|
|
|
|
free_queue_item(&item, 1);
|
|
jparse_free(response);
|
|
|
|
return 0;
|
|
}
|
|
|
|
struct queue_add_album_param {
|
|
struct spotify_album album;
|
|
struct db_queue_add_info queue_add_info;
|
|
};
|
|
|
|
static int
|
|
queue_add_album_tracks(json_object *item, int index, int total, void *arg)
|
|
{
|
|
struct queue_add_album_param *param;
|
|
struct spotify_track track;
|
|
struct db_queue_item queue_item;
|
|
int ret;
|
|
|
|
param = arg;
|
|
|
|
parse_metadata_track(item, &track, ART_DEFAULT_WIDTH);
|
|
|
|
if (!track.uri || !track.is_playable)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Track not available for playback: '%s' - '%s' (%s) (restrictions: %s)\n", track.artist, track.name, track.uri, track.restrictions);
|
|
return -1;
|
|
}
|
|
|
|
map_track_to_queueitem(&queue_item, &track, ¶m->album);
|
|
|
|
ret = db_queue_add_item(¶m->queue_add_info, &queue_item);
|
|
|
|
free_queue_item(&queue_item, 1);
|
|
|
|
return ret;
|
|
}
|
|
|
|
static int
|
|
queue_add_album(const char *uri, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id)
|
|
{
|
|
char *album_endpoint_uri = NULL;
|
|
char *endpoint_uri = NULL;
|
|
json_object *json_album;
|
|
struct queue_add_album_param param;
|
|
int ret;
|
|
|
|
album_endpoint_uri = get_album_endpoint_uri(uri);
|
|
json_album = request_endpoint_with_token_refresh(album_endpoint_uri);
|
|
parse_metadata_album(json_album, ¶m.album, ART_DEFAULT_WIDTH);
|
|
|
|
ret = db_queue_add_start(¶m.queue_add_info, position);
|
|
if (ret < 0)
|
|
goto out;
|
|
|
|
endpoint_uri = get_album_tracks_endpoint_uri(uri);
|
|
|
|
ret = request_pagingobject_endpoint(endpoint_uri, queue_add_album_tracks, NULL, NULL, true, ¶m);
|
|
|
|
ret = db_queue_add_end(¶m.queue_add_info, reshuffle, item_id, ret);
|
|
if (ret == 0 && count)
|
|
*count = param.queue_add_info.count;
|
|
|
|
out:
|
|
free(album_endpoint_uri);
|
|
free(endpoint_uri);
|
|
jparse_free(json_album);
|
|
|
|
return ret;
|
|
}
|
|
|
|
static int
|
|
queue_add_albums(json_object *item, int index, int total, void *arg)
|
|
{
|
|
struct db_queue_add_info *param;
|
|
struct queue_add_album_param param_add_album;
|
|
char *endpoint_uri = NULL;
|
|
int ret;
|
|
|
|
param = arg;
|
|
param_add_album.queue_add_info = *param;
|
|
|
|
parse_metadata_album(item, ¶m_add_album.album, ART_DEFAULT_WIDTH);
|
|
|
|
endpoint_uri = get_album_tracks_endpoint_uri(param_add_album.album.uri);
|
|
ret = request_pagingobject_endpoint(endpoint_uri, queue_add_album_tracks, NULL, NULL, true, ¶m_add_album);
|
|
|
|
*param = param_add_album.queue_add_info;
|
|
|
|
free(endpoint_uri);
|
|
return ret;
|
|
|
|
}
|
|
|
|
static int
|
|
queue_add_artist(const char *uri, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id)
|
|
{
|
|
struct db_queue_add_info queue_add_info;
|
|
char *endpoint_uri = NULL;
|
|
int ret;
|
|
|
|
ret = db_queue_add_start(&queue_add_info, position);
|
|
if (ret < 0)
|
|
return -1;
|
|
|
|
endpoint_uri = get_artist_albums_endpoint_uri(uri);
|
|
ret = request_pagingobject_endpoint(endpoint_uri, queue_add_albums, NULL, NULL, true, &queue_add_info);
|
|
|
|
ret = db_queue_add_end(&queue_add_info, reshuffle, item_id, ret);
|
|
if (ret == 0 && count)
|
|
*count = queue_add_info.count;
|
|
|
|
free(endpoint_uri);
|
|
return ret;
|
|
}
|
|
|
|
static int
|
|
queue_add_playlist_tracks(json_object *item, int index, int total, void *arg)
|
|
{
|
|
struct db_queue_add_info *queue_add_info;
|
|
struct spotify_track track;
|
|
json_object *jsontrack;
|
|
struct db_queue_item queue_item;
|
|
int ret;
|
|
|
|
queue_add_info = arg;
|
|
|
|
if (!(item && json_object_object_get_ex(item, "track", &jsontrack)))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: missing 'track' in JSON object at index %d\n", index);
|
|
return -1;
|
|
}
|
|
|
|
parse_metadata_track(jsontrack, &track, ART_DEFAULT_WIDTH);
|
|
track.added_at = jparse_str_from_obj(item, "added_at");
|
|
track.mtime = jparse_time_from_obj(item, "added_at");
|
|
|
|
if (!track.uri || !track.is_playable)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Track not available for playback: '%s' - '%s' (%s) (restrictions: %s)\n", track.artist, track.name, track.uri, track.restrictions);
|
|
return -1;
|
|
}
|
|
|
|
map_track_to_queueitem(&queue_item, &track, NULL);
|
|
|
|
ret = db_queue_add_item(queue_add_info, &queue_item);
|
|
|
|
free_queue_item(&queue_item, 1);
|
|
|
|
return ret;
|
|
}
|
|
|
|
static int
|
|
queue_add_playlist(const char *uri, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id)
|
|
{
|
|
char *endpoint_uri;
|
|
struct db_queue_add_info queue_add_info;
|
|
int ret;
|
|
|
|
ret = db_queue_add_start(&queue_add_info, position);
|
|
if (ret < 0)
|
|
return -1;
|
|
|
|
endpoint_uri = get_playlist_tracks_endpoint_uri(uri);
|
|
|
|
ret = request_pagingobject_endpoint(endpoint_uri, queue_add_playlist_tracks, NULL, NULL, true, &queue_add_info);
|
|
|
|
ret = db_queue_add_end(&queue_add_info, reshuffle, item_id, ret);
|
|
if (ret == 0 && count)
|
|
*count = queue_add_info.count;
|
|
|
|
free(endpoint_uri);
|
|
|
|
return ret;
|
|
}
|
|
|
|
static int
|
|
queue_item_add(const char *uri, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id)
|
|
{
|
|
if (strncasecmp(uri, "spotify:track:", strlen("spotify:track:")) == 0)
|
|
{
|
|
queue_add_track(uri, position, reshuffle, item_id, count, new_item_id);
|
|
return LIBRARY_OK;
|
|
}
|
|
else if (strncasecmp(uri, "spotify:artist:", strlen("spotify:artist:")) == 0)
|
|
{
|
|
queue_add_artist(uri, position, reshuffle, item_id, count, new_item_id);
|
|
return LIBRARY_OK;
|
|
}
|
|
else if (strncasecmp(uri, "spotify:album:", strlen("spotify:album:")) == 0)
|
|
{
|
|
queue_add_album(uri, position, reshuffle, item_id, count, new_item_id);
|
|
return LIBRARY_OK;
|
|
}
|
|
else if (strncasecmp(uri, "spotify:", strlen("spotify:")) == 0)
|
|
{
|
|
queue_add_playlist(uri, position, reshuffle, item_id, count, new_item_id);
|
|
return LIBRARY_OK;
|
|
}
|
|
|
|
return LIBRARY_PATH_INVALID;
|
|
}
|
|
|
|
|
|
/*
|
|
* Returns the directory id for /spotify:/<artist>/<album>, if the directory (or the parent
|
|
* directories) does not yet exist, they will be created.
|
|
* If an error occured the return value is -1.
|
|
*
|
|
* @return directory id for the given artist/album directory
|
|
*/
|
|
static int
|
|
prepare_directories(const char *artist, const char *album)
|
|
{
|
|
int dir_id;
|
|
char virtual_path[PATH_MAX];
|
|
int ret;
|
|
|
|
ret = snprintf(virtual_path, sizeof(virtual_path), "/spotify:/%s", artist);
|
|
if ((ret < 0) || (ret >= sizeof(virtual_path)))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Virtual path exceeds PATH_MAX (/spotify:/%s)\n", artist);
|
|
return -1;
|
|
}
|
|
dir_id = db_directory_addorupdate(virtual_path, NULL, 0, DIR_SPOTIFY);
|
|
if (dir_id <= 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Could not add or update directory '%s'\n", virtual_path);
|
|
return -1;
|
|
}
|
|
ret = snprintf(virtual_path, sizeof(virtual_path), "/spotify:/%s/%s", artist, album);
|
|
if ((ret < 0) || (ret >= sizeof(virtual_path)))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Virtual path exceeds PATH_MAX (/spotify:/%s/%s)\n", artist, album);
|
|
return -1;
|
|
}
|
|
dir_id = db_directory_addorupdate(virtual_path, NULL, 0, dir_id);
|
|
if (dir_id <= 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Could not add or update directory '%s'\n", virtual_path);
|
|
return -1;
|
|
}
|
|
|
|
return dir_id;
|
|
}
|
|
|
|
/*
|
|
* Purges all Spotify files from the library that are not in a playlist
|
|
* (Note: all files from saved albums are in the spotify:savedtracks playlist)
|
|
*/
|
|
static int
|
|
cleanup_spotify_files(void)
|
|
{
|
|
struct query_params qp;
|
|
char *path;
|
|
int ret;
|
|
|
|
memset(&qp, 0, sizeof(struct query_params));
|
|
|
|
qp.type = Q_BROWSE_PATH;
|
|
qp.sort = S_NONE;
|
|
qp.filter = "f.path LIKE 'spotify:%%' AND NOT f.path IN (SELECT filepath FROM playlistitems)";
|
|
|
|
ret = db_query_start(&qp);
|
|
if (ret < 0)
|
|
{
|
|
db_query_end(&qp);
|
|
return -1;
|
|
}
|
|
|
|
while (((ret = db_query_fetch_string(&qp, &path)) == 0) && (path))
|
|
{
|
|
cache_artwork_delete_by_path(path);
|
|
}
|
|
|
|
db_query_end(&qp);
|
|
|
|
db_spotify_files_delete();
|
|
|
|
return 0;
|
|
}
|
|
|
|
static void
|
|
map_track_to_mfi(struct media_file_info *mfi, const struct spotify_track *track, const struct spotify_album *album, const char *pl_name)
|
|
{
|
|
char virtual_path[PATH_MAX];
|
|
|
|
mfi->title = safe_strdup(track->name);
|
|
mfi->artist = safe_strdup(track->artist);
|
|
mfi->disc = track->disc_number;
|
|
mfi->song_length = track->duration_ms;
|
|
mfi->track = track->track_number;
|
|
|
|
mfi->data_kind = DATA_KIND_SPOTIFY;
|
|
mfi->media_kind = MEDIA_KIND_MUSIC;
|
|
mfi->type = strdup("spotify");
|
|
mfi->codectype = strdup("wav");
|
|
mfi->description = strdup("Spotify audio");
|
|
|
|
mfi->path = strdup(track->uri);
|
|
mfi->fname = strdup(track->uri);
|
|
|
|
mfi->time_modified = track->mtime;
|
|
mfi->time_added = track->mtime;
|
|
|
|
if (album)
|
|
{
|
|
mfi->album_artist = safe_strdup(album->artist);
|
|
mfi->album = safe_strdup(album->name);
|
|
mfi->genre = safe_strdup(album->genre);
|
|
mfi->compilation = album->is_compilation;
|
|
mfi->year = album->release_year;
|
|
}
|
|
else
|
|
{
|
|
mfi->album_artist = safe_strdup(track->album_artist);
|
|
if (cfg_getbool(cfg_getsec(cfg, "spotify"), "album_override") && pl_name)
|
|
mfi->album = safe_strdup(pl_name);
|
|
else
|
|
mfi->album = safe_strdup(track->album);
|
|
|
|
if (cfg_getbool(cfg_getsec(cfg, "spotify"), "artist_override") && pl_name)
|
|
mfi->compilation = true;
|
|
else
|
|
mfi->compilation = track->is_compilation;
|
|
}
|
|
|
|
snprintf(virtual_path, PATH_MAX, "/spotify:/%s/%s/%s", mfi->album_artist, mfi->album, mfi->title);
|
|
mfi->virtual_path = strdup(virtual_path);
|
|
}
|
|
|
|
static int
|
|
track_add(struct spotify_track *track, struct spotify_album *album, const char *pl_name, int dir_id)
|
|
{
|
|
struct media_file_info mfi;
|
|
int ret;
|
|
|
|
if (!track->uri || !track->is_playable)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Track not available for playback: '%s' - '%s' (%s) (restrictions: %s)\n",
|
|
track->artist, track->name, track->uri, track->restrictions);
|
|
return -1;
|
|
}
|
|
|
|
if (track->linked_from_uri)
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Track '%s' (%s) linked from %s\n", track->name, track->uri, track->linked_from_uri);
|
|
|
|
ret = db_file_ping_bypath(track->uri, track->mtime);
|
|
if (ret == 0)
|
|
{
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Track '%s' (%s) is new or modified (mtime is %" PRIi64 ")\n",
|
|
track->name, track->uri, (int64_t)track->mtime);
|
|
|
|
memset(&mfi, 0, sizeof(struct media_file_info));
|
|
|
|
mfi.id = db_file_id_bypath(track->uri);
|
|
mfi.directory_id = dir_id;
|
|
|
|
map_track_to_mfi(&mfi, track, album, pl_name);
|
|
|
|
library_media_save(&mfi);
|
|
|
|
free_mfi(&mfi, 1);
|
|
}
|
|
|
|
spotify_uri_register(track->uri);
|
|
|
|
if (album)
|
|
cache_artwork_ping(track->uri, album->mtime, 0);
|
|
else
|
|
cache_artwork_ping(track->uri, 1, 0);
|
|
|
|
return 0;
|
|
}
|
|
|
|
static int
|
|
playlist_add_or_update(struct playlist_info *pli)
|
|
{
|
|
int pl_id;
|
|
|
|
pl_id = db_pl_id_bypath(pli->path);
|
|
if (pl_id < 0)
|
|
return library_playlist_save(pli);
|
|
|
|
pli->id = pl_id;
|
|
|
|
db_pl_clear_items(pli->id);
|
|
|
|
return library_playlist_save(pli);
|
|
}
|
|
|
|
/*
|
|
* Add a saved album to the library
|
|
*/
|
|
static int
|
|
saved_album_add(json_object *item, int index, int total, void *arg)
|
|
{
|
|
json_object *jsonalbum;
|
|
struct spotify_album album;
|
|
struct spotify_track track;
|
|
json_object *needle;
|
|
json_object *jsontracks;
|
|
json_object *jsontrack;
|
|
int track_count;
|
|
int dir_id;
|
|
int i;
|
|
int ret;
|
|
|
|
if (!json_object_object_get_ex(item, "album", &jsonalbum))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: Item %d is missing the 'album' field\n", index);
|
|
return -1;
|
|
}
|
|
if (!json_object_object_get_ex(jsonalbum, "tracks", &needle))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: Item %d is missing the 'tracks' field'\n", index);
|
|
return -1;
|
|
}
|
|
if (jparse_array_from_obj(needle, "items", &jsontracks) < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: Item %d has an empty 'tracks' array\n", index);
|
|
return -1;
|
|
}
|
|
|
|
// Map album information
|
|
parse_metadata_album(jsonalbum, &album, 0);
|
|
album.added_at = jparse_str_from_obj(item, "added_at");
|
|
album.mtime = jparse_time_from_obj(item, "added_at");
|
|
|
|
// Now map the album tracks and insert/update them in the files database
|
|
db_transaction_begin();
|
|
|
|
// Get or create the directory structure for this album
|
|
dir_id = prepare_directories(album.artist, album.name);
|
|
|
|
track_count = json_object_array_length(jsontracks);
|
|
for (i = 0; i < track_count; i++)
|
|
{
|
|
jsontrack = json_object_array_get_idx(jsontracks, i);
|
|
if (!jsontrack)
|
|
break;
|
|
|
|
parse_metadata_track(jsontrack, &track, 0);
|
|
track.mtime = album.mtime;
|
|
|
|
ret = track_add(&track, &album, NULL, dir_id);
|
|
|
|
if (ret == 0 && spotify_saved_plid)
|
|
db_pl_add_item_bypath(spotify_saved_plid, track.uri);
|
|
}
|
|
|
|
db_transaction_end();
|
|
|
|
if ((index + 1) >= total || ((index + 1) % 10 == 0))
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Scanned %d of %d saved albums\n", (index + 1), total);
|
|
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* Thread: library
|
|
*
|
|
* Scan users saved albums into the library
|
|
*/
|
|
static int
|
|
scan_saved_albums()
|
|
{
|
|
int ret;
|
|
|
|
ret = request_pagingobject_endpoint(spotify_albums_uri, saved_album_add, NULL, NULL, true, NULL);
|
|
|
|
return ret;
|
|
}
|
|
|
|
|
|
/*
|
|
* Add a saved playlist tracks to the library
|
|
*/
|
|
static int
|
|
saved_playlist_tracks_add(json_object *item, int index, int total, void *arg)
|
|
{
|
|
struct spotify_track track;
|
|
json_object *jsontrack;
|
|
int *plid;
|
|
int dir_id;
|
|
int ret;
|
|
|
|
plid = arg;
|
|
|
|
if (!(item && json_object_object_get_ex(item, "track", &jsontrack)))
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Unexpected JSON: missing 'track' in JSON object at index %d\n", index);
|
|
return -1;
|
|
}
|
|
|
|
parse_metadata_track(jsontrack, &track, 0);
|
|
track.added_at = jparse_str_from_obj(item, "added_at");
|
|
track.mtime = jparse_time_from_obj(item, "added_at");
|
|
|
|
if (!track.uri || !track.is_playable)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Track not available for playback: '%s' - '%s' (%s) (restrictions: %s)\n", track.artist, track.name, track.uri, track.restrictions);
|
|
return 0;
|
|
}
|
|
|
|
dir_id = prepare_directories(track.album_artist, track.album);
|
|
ret = track_add(&track, NULL, NULL, dir_id);
|
|
if (ret == 0)
|
|
db_pl_add_item_bypath(*plid, track.uri);
|
|
|
|
return 0;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static int
|
|
scan_playlist_tracks(const char *playlist_tracks_endpoint_uri, int plid)
|
|
{
|
|
int ret;
|
|
|
|
ret = request_pagingobject_endpoint(playlist_tracks_endpoint_uri, saved_playlist_tracks_add, transaction_start, transaction_end, true, &plid);
|
|
|
|
return ret;
|
|
}
|
|
|
|
static void
|
|
map_playlist_to_pli(struct playlist_info *pli, struct spotify_playlist *playlist)
|
|
{
|
|
memset(pli, 0, sizeof(struct playlist_info));
|
|
|
|
pli->type = PL_PLAIN;
|
|
pli->path = strdup(playlist->uri);
|
|
pli->title = safe_strdup(playlist->name);
|
|
|
|
pli->parent_id = spotify_base_plid;
|
|
pli->directory_id = DIR_SPOTIFY;
|
|
|
|
if (playlist->owner)
|
|
pli->virtual_path = safe_asprintf("/spotify:/%s (%s)", playlist->name, playlist->owner);
|
|
else
|
|
pli->virtual_path = safe_asprintf("/spotify:/%s", playlist->name);
|
|
}
|
|
|
|
/*
|
|
* Add a saved playlist to the library
|
|
*/
|
|
static int
|
|
saved_playlist_add(json_object *item, int index, int total, void *arg)
|
|
{
|
|
struct spotify_playlist playlist;
|
|
struct playlist_info pli;
|
|
int pl_id;
|
|
|
|
// Map playlist information
|
|
parse_metadata_playlist(item, &playlist);
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Got playlist: '%s' with %d tracks (%s) \n", playlist.name, playlist.tracks_count, playlist.uri);
|
|
|
|
if (!playlist.uri || !playlist.name || playlist.tracks_count == 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Ignoring playlist '%s' with %d tracks (%s)\n", playlist.name, playlist.tracks_count, playlist.uri);
|
|
return -1;
|
|
}
|
|
|
|
map_playlist_to_pli(&pli, &playlist);
|
|
|
|
pl_id = playlist_add_or_update(&pli);
|
|
|
|
free_pli(&pli, 1);
|
|
|
|
if (pl_id > 0)
|
|
scan_playlist_tracks(playlist.tracks_href, pl_id);
|
|
else
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error adding playlist: '%s' (%s) \n", playlist.name, playlist.uri);
|
|
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Scanned %d of %d saved playlists\n", (index + 1), total);
|
|
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* Thread: library
|
|
*
|
|
* Scan users saved playlists into the library
|
|
*/
|
|
static int
|
|
scan_playlists()
|
|
{
|
|
int ret;
|
|
|
|
ret = request_pagingobject_endpoint(spotify_playlists_uri, saved_playlist_add, NULL, NULL, false, NULL);
|
|
|
|
return ret;
|
|
}
|
|
|
|
static void
|
|
create_saved_tracks_playlist()
|
|
{
|
|
struct playlist_info pli =
|
|
{
|
|
.path = strdup("spotify:savedtracks"),
|
|
.title = strdup("Spotify Saved"),
|
|
.virtual_path = strdup("/spotify:/Spotify Saved"),
|
|
.type = PL_PLAIN,
|
|
.parent_id = spotify_base_plid,
|
|
.directory_id = DIR_SPOTIFY,
|
|
};
|
|
|
|
spotify_saved_plid = playlist_add_or_update(&pli);
|
|
if (spotify_saved_plid < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error adding playlist for saved tracks\n");
|
|
spotify_saved_plid = 0;
|
|
}
|
|
|
|
free_pli(&pli, 1);
|
|
}
|
|
|
|
/*
|
|
* Add or update playlist folder for all spotify playlists (if enabled in config)
|
|
*/
|
|
static void
|
|
create_base_playlist()
|
|
{
|
|
cfg_t *spotify_cfg;
|
|
struct playlist_info pli =
|
|
{
|
|
.path = strdup("spotify:playlistfolder"),
|
|
.title = strdup("Spotify"),
|
|
.type = PL_FOLDER,
|
|
};
|
|
|
|
spotify_base_plid = 0;
|
|
spotify_cfg = cfg_getsec(cfg, "spotify");
|
|
if (cfg_getbool(spotify_cfg, "base_playlist_disable"))
|
|
{
|
|
free_pli(&pli, 1);
|
|
return;
|
|
}
|
|
|
|
spotify_base_plid = playlist_add_or_update(&pli);
|
|
if (spotify_base_plid < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Error adding base playlist\n");
|
|
spotify_base_plid = 0;
|
|
}
|
|
|
|
free_pli(&pli, 1);
|
|
}
|
|
|
|
static void
|
|
scan()
|
|
{
|
|
time_t start;
|
|
time_t end;
|
|
|
|
if (!token_valid() || scanning)
|
|
{
|
|
DPRINTF(E_DBG, L_SPOTIFY, "No valid web api token or scan already in progress, rescan ignored\n");
|
|
return;
|
|
}
|
|
|
|
start = time(NULL);
|
|
scanning = true;
|
|
|
|
db_directory_enable_bypath("/spotify:");
|
|
create_base_playlist();
|
|
create_saved_tracks_playlist();
|
|
scan_saved_albums();
|
|
scan_playlists();
|
|
|
|
scanning = false;
|
|
end = time(NULL);
|
|
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Spotify scan completed in %.f sec\n", difftime(end, start));
|
|
}
|
|
|
|
/* Thread: library */
|
|
static int
|
|
initscan()
|
|
{
|
|
int ret;
|
|
|
|
/* Refresh access token for the spotify webapi */
|
|
ret = token_refresh();
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Spotify webapi token refresh failed. "
|
|
"In order to use the web api, authorize forked-daapd to access "
|
|
"your saved tracks by visiting http://forked-daapd.local:3689\n");
|
|
|
|
db_spotify_purge();
|
|
|
|
return 0;
|
|
}
|
|
|
|
spotify_saved_plid = 0;
|
|
|
|
/*
|
|
* Login to spotify needs to be done before scanning tracks from the web api.
|
|
* (Scanned tracks need to be registered with libspotify for playback)
|
|
*/
|
|
ret = spotify_relogin();
|
|
if (ret < 0)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "libspotify-login failed. In order to use Spotify, "
|
|
"provide valid credentials for libspotify by visiting http://forked-daapd.local:3689\n");
|
|
|
|
db_spotify_purge();
|
|
|
|
return 0;
|
|
}
|
|
|
|
/*
|
|
* Scan saved tracks from the web api
|
|
*/
|
|
scan();
|
|
|
|
return 0;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static int
|
|
rescan()
|
|
{
|
|
scan();
|
|
return 0;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static int
|
|
fullrescan()
|
|
{
|
|
db_spotify_purge();
|
|
scan();
|
|
return 0;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static enum command_state
|
|
webapi_fullrescan(void *arg, int *ret)
|
|
{
|
|
*ret = fullrescan();
|
|
return COMMAND_END;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static enum command_state
|
|
webapi_rescan(void *arg, int *ret)
|
|
{
|
|
*ret = rescan();
|
|
return COMMAND_END;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static enum command_state
|
|
webapi_pl_save(void *arg, int *ret)
|
|
{
|
|
const char *uri;
|
|
char *endpoint_uri;
|
|
json_object *response;
|
|
|
|
uri = arg;
|
|
endpoint_uri = get_playlist_endpoint_uri(uri);
|
|
|
|
response = request_endpoint_with_token_refresh(endpoint_uri);
|
|
if (!response)
|
|
{
|
|
*ret = -1;
|
|
goto out;
|
|
}
|
|
|
|
*ret = saved_playlist_add(response, 0, 1, NULL);
|
|
|
|
jparse_free(response);
|
|
|
|
out:
|
|
free(endpoint_uri);
|
|
|
|
return COMMAND_END;
|
|
}
|
|
|
|
/* Thread: library */
|
|
static enum command_state
|
|
webapi_pl_remove(void *arg, int *ret)
|
|
{
|
|
const char *uri;
|
|
struct playlist_info *pli;
|
|
int plid;
|
|
|
|
uri = arg;
|
|
pli = db_pl_fetch_bypath(uri);
|
|
|
|
if (!pli)
|
|
{
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Playlist '%s' not found, can't delete\n", uri);
|
|
|
|
*ret = -1;
|
|
return COMMAND_END;
|
|
}
|
|
|
|
DPRINTF(E_LOG, L_SPOTIFY, "Removing playlist '%s' (%s)\n", pli->title, uri);
|
|
|
|
plid = pli->id;
|
|
|
|
free_pli(pli, 0);
|
|
|
|
db_spotify_pl_delete(plid);
|
|
cleanup_spotify_files();
|
|
*ret = 0;
|
|
|
|
return COMMAND_END;
|
|
}
|
|
|
|
void
|
|
spotifywebapi_fullrescan(void)
|
|
{
|
|
library_exec_async(webapi_fullrescan, NULL);
|
|
}
|
|
|
|
void
|
|
spotifywebapi_rescan(void)
|
|
{
|
|
library_exec_async(webapi_rescan, NULL);
|
|
}
|
|
|
|
void
|
|
spotifywebapi_pl_save(const char *uri)
|
|
{
|
|
if (scanning || !token_valid())
|
|
{
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Scanning spotify saved tracks still in progress, ignoring update trigger for single playlist '%s'\n", uri);
|
|
return;
|
|
}
|
|
|
|
library_exec_async(webapi_pl_save, strdup(uri));
|
|
}
|
|
|
|
void
|
|
spotifywebapi_pl_remove(const char *uri)
|
|
{
|
|
if (scanning || !token_valid())
|
|
{
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Scanning spotify saved tracks still in progress, ignoring remove trigger for single playlist '%s'\n", uri);
|
|
return;
|
|
}
|
|
|
|
library_exec_async(webapi_pl_remove, strdup(uri));
|
|
}
|
|
|
|
char *
|
|
spotifywebapi_artwork_url_get(const char *uri, int max_w, int max_h)
|
|
{
|
|
json_object *response;
|
|
struct spotify_track track;
|
|
char *artwork_url;
|
|
|
|
response = request_track(uri);
|
|
if (!response)
|
|
{
|
|
return NULL;
|
|
}
|
|
|
|
parse_metadata_track(response, &track, max_w);
|
|
|
|
DPRINTF(E_DBG, L_SPOTIFY, "Got track artwork url: '%s' (%s) \n", track.artwork_url, track.uri);
|
|
|
|
artwork_url = safe_strdup(track.artwork_url);
|
|
jparse_free(response);
|
|
|
|
return artwork_url;
|
|
}
|
|
|
|
void
|
|
spotifywebapi_status_info_get(struct spotifywebapi_status_info *info)
|
|
{
|
|
memset(info, 0, sizeof(struct spotifywebapi_status_info));
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_lock(&token_lck));
|
|
|
|
info->token_valid = token_valid();
|
|
if (spotify_user)
|
|
{
|
|
strncpy(info->user, spotify_user, (sizeof(info->user) - 1));
|
|
}
|
|
if (spotify_user_country)
|
|
{
|
|
strncpy(info->country, spotify_user_country, (sizeof(info->country) - 1));
|
|
}
|
|
if (spotify_granted_scope)
|
|
{
|
|
strncpy(info->granted_scope, spotify_granted_scope, (sizeof(info->granted_scope) - 1));
|
|
}
|
|
if (spotify_scope)
|
|
{
|
|
strncpy(info->required_scope, spotify_scope, (sizeof(info->required_scope) - 1));
|
|
}
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_unlock(&token_lck));
|
|
}
|
|
|
|
void
|
|
spotifywebapi_access_token_get(struct spotifywebapi_access_token *info)
|
|
{
|
|
token_refresh();
|
|
|
|
memset(info, 0, sizeof(struct spotifywebapi_access_token));
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_lock(&token_lck));
|
|
|
|
if (token_requested > 0)
|
|
info->expires_in = expires_in - difftime(time(NULL), token_requested);
|
|
else
|
|
info->expires_in = 0;
|
|
|
|
info->token = safe_strdup(spotify_access_token);
|
|
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_unlock(&token_lck));
|
|
}
|
|
|
|
static int
|
|
spotifywebapi_init()
|
|
{
|
|
int ret;
|
|
|
|
CHECK_ERR(L_SPOTIFY, mutex_init(&token_lck));
|
|
ret = spotify_init();
|
|
|
|
return ret;
|
|
}
|
|
|
|
static void
|
|
spotifywebapi_deinit()
|
|
{
|
|
CHECK_ERR(L_SPOTIFY, pthread_mutex_destroy(&token_lck));
|
|
|
|
spotify_deinit();
|
|
|
|
free(spotify_access_token);
|
|
free(spotify_refresh_token);
|
|
free(spotify_granted_scope);
|
|
free(spotify_user_country);
|
|
free(spotify_user);
|
|
}
|
|
|
|
struct library_source spotifyscanner =
|
|
{
|
|
.name = "spotifyscanner",
|
|
.disabled = 0,
|
|
.init = spotifywebapi_init,
|
|
.deinit = spotifywebapi_deinit,
|
|
.rescan = rescan,
|
|
.metarescan = rescan,
|
|
.initscan = initscan,
|
|
.fullrescan = fullrescan,
|
|
.queue_item_add = queue_item_add,
|
|
};
|
|
|