1
0
mirror of https://github.com/strawberrymusicplayer/strawberry synced 2024-12-10 15:55:29 +01:00

Add tidal cover provider

This commit is contained in:
Jonas Kvinge 2019-04-14 16:40:05 +02:00
parent 36dccc8157
commit 1ad163aac3
20 changed files with 404 additions and 23 deletions

View File

@ -19,7 +19,7 @@ Strawberry is a music player and music collection organizer. It is a fork of Cle
* Advanced audio output and device configuration for bit-perfect playback on Linux
* Edit tags on music files
* Fetch tags from MusicBrainz
* Album cover art from Last.fm, Musicbrainz, Discogs and Deezer
* Album cover art from Last.fm, Musicbrainz, Discogs, Deezer and Tidal
* Song lyrics from AudD
* Support for multiple backends
* Audio analyzer

2
dist/debian/control vendored
View File

@ -58,7 +58,7 @@ Description: Audio player and music collection organizer
- Advanced audio output and device configuration for bit-perfect playback on Linux
- Edit tags on music files
- Fetch tags from MusicBrainz
- Album cover art from Lastfm, Musicbrainz, Discogs and Deezer
- Album cover art from Lastfm, Musicbrainz, Discogs, Deezer and Tidal
- Song lyrics from AudD
- Support for multiple backends
- Audio analyzer

View File

@ -25,7 +25,7 @@ Features:
.br
- Fetch tags from MusicBrainz
.br
- Album cover art from Lastfm, Musicbrainz, Discogs and Deezer
- Album cover art from Lastfm, Musicbrainz, Discogs, Deezer and Tidal
.br
- Song lyrics from AudD
.br

View File

@ -96,7 +96,7 @@ Features:
- Advanced audio output and device configuration for bit-perfect playback on Linux
- Edit tags on music files
- Fetch tags from MusicBrainz
- Album cover art from Last.fm, Musicbrainz, Discogs and Deezer
- Album cover art from Last.fm, Musicbrainz, Discogs, Deezer and Tidal
- Song lyrics from AudD
- Support for multiple backends
- Audio analyzer

View File

@ -199,6 +199,7 @@ set(SOURCES
covermanager/musicbrainzcoverprovider.cpp
covermanager/discogscoverprovider.cpp
covermanager/deezercoverprovider.cpp
covermanager/tidalcoverprovider.cpp
lyrics/lyricsproviders.cpp
lyrics/lyricsprovider.cpp
@ -375,6 +376,7 @@ set(HEADERS
covermanager/musicbrainzcoverprovider.h
covermanager/discogscoverprovider.h
covermanager/deezercoverprovider.h
covermanager/tidalcoverprovider.h
lyrics/lyricsproviders.h
lyrics/lyricsprovider.h

View File

@ -55,6 +55,7 @@
#include "covermanager/discogscoverprovider.h"
#include "covermanager/musicbrainzcoverprovider.h"
#include "covermanager/deezercoverprovider.h"
#include "covermanager/tidalcoverprovider.h"
#include "lyrics/lyricsproviders.h"
#include "lyrics/lyricsprovider.h"
@ -103,10 +104,11 @@ class ApplicationImpl {
cover_providers_([=]() {
CoverProviders *cover_providers = new CoverProviders(app);
// Initialize the repository of cover providers.
cover_providers->AddProvider(new LastFmCoverProvider(app));
cover_providers->AddProvider(new DiscogsCoverProvider(app));
cover_providers->AddProvider(new MusicbrainzCoverProvider(app));
cover_providers->AddProvider(new DeezerCoverProvider(app));
cover_providers->AddProvider(new LastFmCoverProvider(app, app));
cover_providers->AddProvider(new DiscogsCoverProvider(app, app));
cover_providers->AddProvider(new MusicbrainzCoverProvider(app, app));
cover_providers->AddProvider(new DeezerCoverProvider(app, app));
cover_providers->AddProvider(new TidalCoverProvider(app, app));
return cover_providers;
}),
album_cover_loader_([=]() {

View File

@ -23,7 +23,8 @@
#include <QObject>
#include <QString>
#include "core/application.h"
#include "coverprovider.h"
CoverProvider::CoverProvider(const QString &name, const bool &fetchall, QObject *parent)
: QObject(parent), name_(name), fetchall_(fetchall) {}
CoverProvider::CoverProvider(const QString &name, const bool &fetchall, Application *app, QObject *parent)
: QObject(parent), app_(app), name_(name), fetchall_(fetchall) {}

View File

@ -29,6 +29,7 @@
#include <QList>
#include <QString>
class Application;
struct CoverSearchResult;
// Each implementation of this interface downloads covers from one online service.
@ -36,8 +37,8 @@ struct CoverSearchResult;
class CoverProvider : public QObject {
Q_OBJECT
public:
explicit CoverProvider(const QString &name, const bool &fetchall, QObject *parent);
public:
explicit CoverProvider(const QString &name, const bool &fetchall, Application *app, QObject *parent);
// A name (very short description) of this provider, like "last.fm".
QString name() const { return name_; }
@ -50,10 +51,11 @@ public:
virtual void CancelSearch(int id) {}
signals:
signals:
void SearchFinished(int id, const QList<CoverSearchResult>& results);
private:
private:
Application *app_;
QString name_;
bool fetchall_;

View File

@ -61,7 +61,7 @@ class CoverProviders : public QObject {
private:
Q_DISABLE_COPY(CoverProviders);
QMap<CoverProvider *, QString> cover_providers_;
QMap<CoverProvider*, QString> cover_providers_;
QMutex mutex_;
QAtomicInt next_id_;

View File

@ -37,6 +37,7 @@
#include <QJsonObject>
#include <QJsonArray>
#include "core/application.h"
#include "core/closure.h"
#include "core/network.h"
#include "core/logging.h"
@ -47,7 +48,7 @@
const char *DeezerCoverProvider::kApiUrl = "https://api.deezer.com";
const int DeezerCoverProvider::kLimit = 10;
DeezerCoverProvider::DeezerCoverProvider(QObject *parent): CoverProvider("Deezer", true, parent), network_(new NetworkAccessManager(this)) {}
DeezerCoverProvider::DeezerCoverProvider(Application *app, QObject *parent): CoverProvider("Deezer", true, app, parent), network_(new NetworkAccessManager(this)) {}
bool DeezerCoverProvider::StartSearch(const QString &artist, const QString &album, int id) {

View File

@ -36,11 +36,13 @@
#include "coverprovider.h"
class Application;
class DeezerCoverProvider : public CoverProvider {
Q_OBJECT
public:
explicit DeezerCoverProvider(QObject *parent = nullptr);
explicit DeezerCoverProvider(Application *app, QObject *parent = nullptr);
bool StartSearch(const QString &artist, const QString &album, int id);
void CancelSearch(int id);

View File

@ -39,6 +39,7 @@
#include <QJsonValue>
#include <QJsonArray>
#include "core/application.h"
#include "core/closure.h"
#include "core/logging.h"
#include "core/network.h"
@ -53,7 +54,7 @@ const char *DiscogsCoverProvider::kUrlReleases = "https://api.discogs.com/releas
const char *DiscogsCoverProvider::kAccessKeyB64 = "dGh6ZnljUGJlZ1NEeXBuSFFxSVk=";
const char *DiscogsCoverProvider::kSecretKeyB64 = "ZkFIcmlaSER4aHhRSlF2U3d0bm5ZVmdxeXFLWUl0UXI=";
DiscogsCoverProvider::DiscogsCoverProvider(QObject *parent) : CoverProvider("Discogs", false, parent), network_(new NetworkAccessManager(this)) {}
DiscogsCoverProvider::DiscogsCoverProvider(Application *app, QObject *parent) : CoverProvider("Discogs", false, app, parent), network_(new NetworkAccessManager(this)) {}
bool DiscogsCoverProvider::StartSearch(const QString &artist, const QString &album, int s_id) {

View File

@ -36,6 +36,8 @@
#include "coverprovider.h"
#include "albumcoverfetcher.h"
class Application;
// This struct represents a single search-for-cover request. It identifies and describes the request.
struct DiscogsCoverSearchContext {
@ -66,7 +68,7 @@ class DiscogsCoverProvider : public CoverProvider {
Q_OBJECT
public:
explicit DiscogsCoverProvider(QObject *parent = nullptr);
explicit DiscogsCoverProvider(Application *app, QObject *parent = nullptr);
bool StartSearch(const QString &artist, const QString &album, int s_id);

View File

@ -35,6 +35,7 @@
#include <QJsonObject>
#include <QJsonArray>
#include "core/application.h"
#include "core/closure.h"
#include "core/network.h"
#include "core/logging.h"
@ -47,7 +48,7 @@ const char *LastFmCoverProvider::kUrl = "https://ws.audioscrobbler.com/2.0/";
const char *LastFmCoverProvider::kApiKey = "211990b4c96782c05d1536e7219eb56e";
const char *LastFmCoverProvider::kSecret = "80fd738f49596e9709b1bf9319c444a8";
LastFmCoverProvider::LastFmCoverProvider(QObject *parent) : CoverProvider("last.fm", true, parent), network_(new NetworkAccessManager(this)) {}
LastFmCoverProvider::LastFmCoverProvider(Application *app, QObject *parent) : CoverProvider("last.fm", true, app, parent), network_(new NetworkAccessManager(this)) {}
bool LastFmCoverProvider::StartSearch(const QString &artist, const QString &album, int id) {

View File

@ -36,11 +36,13 @@
#include "coverprovider.h"
class Application;
class LastFmCoverProvider : public CoverProvider {
Q_OBJECT
public:
explicit LastFmCoverProvider(QObject *parent = nullptr);
explicit LastFmCoverProvider(Application *app, QObject *parent = nullptr);
bool StartSearch(const QString &artist, const QString &album, int id);
private slots:

View File

@ -37,6 +37,7 @@
#include <QJsonObject>
#include <QJsonArray>
#include "core/application.h"
#include "core/closure.h"
#include "core/network.h"
#include "core/logging.h"
@ -48,7 +49,7 @@ const char *MusicbrainzCoverProvider::kReleaseSearchUrl = "https://musicbrainz.o
const char *MusicbrainzCoverProvider::kAlbumCoverUrl = "https://coverartarchive.org/release/%1/front";
const int MusicbrainzCoverProvider::kLimit = 8;
MusicbrainzCoverProvider::MusicbrainzCoverProvider(QObject *parent): CoverProvider("MusicBrainz", true, parent), network_(new NetworkAccessManager(this)) {}
MusicbrainzCoverProvider::MusicbrainzCoverProvider(Application *app, QObject *parent): CoverProvider("MusicBrainz", true, app, parent), network_(new NetworkAccessManager(this)) {}
bool MusicbrainzCoverProvider::StartSearch(const QString &artist, const QString &album, int id) {

View File

@ -35,10 +35,12 @@
#include "coverprovider.h"
#include "albumcoverfetcher.h"
class Application;
class MusicbrainzCoverProvider : public CoverProvider {
Q_OBJECT
public:
explicit MusicbrainzCoverProvider(QObject *parent = nullptr);
explicit MusicbrainzCoverProvider(Application *app, QObject *parent = nullptr);
bool StartSearch(const QString &artist, const QString &album, int id);
void CancelSearch(int id);

View File

@ -0,0 +1,276 @@
/*
* Strawberry Music Player
* Copyright 2018, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry 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 3 of the License, or
* (at your option) any later version.
*
* Strawberry 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 Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <algorithm>
#include <functional>
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QSettings>
#include "core/application.h"
#include "core/closure.h"
#include "core/network.h"
#include "core/logging.h"
#include "settings/tidalsettingspage.h"
#include "tidal/tidalservice.h"
#include "albumcoverfetcher.h"
#include "coverprovider.h"
#include "tidalcoverprovider.h"
const char *TidalCoverProvider::kApiUrl = "https://listen.tidal.com/v1";
const char *TidalCoverProvider::kResourcesUrl = "http://resources.tidal.com";
const char *TidalCoverProvider::kApiTokenB64 = "UDVYYmVvNUxGdkVTZUR5Ng==";
const int TidalCoverProvider::kLimit = 10;
TidalCoverProvider::TidalCoverProvider(Application *app, QObject *parent) :
CoverProvider("Tidal", true, app, parent),
service_(app->internet_services()->Service<TidalService>()),
network_(new NetworkAccessManager(this)) {
}
bool TidalCoverProvider::StartSearch(const QString &artist, const QString &album, int id) {
if (!service_ || !service_->authenticated()) return false;
QList<Param> parameters;
parameters << Param("query", QString(artist + " " + album));
parameters << Param("limit", QString::number(kLimit));
QNetworkReply *reply = CreateRequest("search/albums", parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleSearchReply(QNetworkReply*, int)), reply, id);
return true;
}
void TidalCoverProvider::CancelSearch(int id) {}
QNetworkReply *TidalCoverProvider::CreateRequest(const QString &ressource_name, const QList<Param> &params_supplied) {
typedef QPair<QString, QString> Param;
typedef QList<Param> ParamList;
typedef QPair<QByteArray, QByteArray> EncodedParam;
typedef QList<EncodedParam> EncodedParamList;
ParamList parameters = ParamList()
<< params_supplied
<< Param("sessionId", service_->session_id())
<< Param("countryCode", service_->country_code());
QStringList query_items;
QUrlQuery url_query;
for (const Param& param : parameters) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
query_items << QString(encoded_param.first + "=" + encoded_param.second);
url_query.addQueryItem(encoded_param.first, encoded_param.second);
}
QUrl url(kApiUrl + QString("/") + ressource_name);
url.setQuery(url_query);
QNetworkRequest req(url);
req.setRawHeader("Origin", "http://listen.tidal.com");
req.setRawHeader("X-Tidal-SessionId", service_->session_id().toUtf8());
QNetworkReply *reply = network_->get(req);
return reply;
}
QByteArray TidalCoverProvider::GetReplyData(QNetworkReply *reply, QString &error) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError) {
data = reply->readAll();
}
else {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
// See if there is Json data containing "userMessage" - then use that instead.
data = reply->readAll();
QJsonParseError parse_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error);
int status = 0;
int sub_status = 0;
QString failure_reason;
if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains("status") && json_obj.contains("userMessage")) {
status = json_obj["status"].toInt();
sub_status = json_obj["subStatus"].toInt();
QString user_message = json_obj["userMessage"].toString();
failure_reason = QString("%1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status);
}
}
if (failure_reason.isEmpty()) {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
if (status == 401 && sub_status == 6001) { // User does not have a valid session
service_->Logout();
}
error = Error(failure_reason);
}
return QByteArray();
}
return data;
}
QJsonObject TidalCoverProvider::ExtractJsonObj(QByteArray &data, QString &error) {
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
error = Error("Reply from server missing Json data.", data);
return QJsonObject();
}
if (json_doc.isNull() || json_doc.isEmpty()) {
error = Error("Received empty Json document.", data);
return QJsonObject();
}
if (!json_doc.isObject()) {
error = Error("Json document is not an object.", json_doc);
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
error = Error("Received empty Json object.", json_doc);
return QJsonObject();
}
return json_obj;
}
QJsonValue TidalCoverProvider::ExtractItems(QByteArray &data, QString &error) {
QJsonObject json_obj = ExtractJsonObj(data, error);
if (json_obj.isEmpty()) return QJsonValue();
return ExtractItems(json_obj, error);
}
QJsonValue TidalCoverProvider::ExtractItems(QJsonObject &json_obj, QString &error) {
if (!json_obj.contains("items")) {
error = Error("Json reply is missing items.", json_obj);
return QJsonArray();
}
QJsonValue json_items = json_obj["items"];
return json_items;
}
void TidalCoverProvider::HandleSearchReply(QNetworkReply *reply, int id) {
reply->deleteLater();
CoverSearchResults results;
QString error;
QByteArray data = GetReplyData(reply, error);
if (data.isEmpty()) {
emit SearchFinished(id, results);
return;
}
QJsonObject json_obj = ExtractJsonObj(data, error);
if (json_obj.isEmpty()) {
emit SearchFinished(id, results);
return;
}
QJsonValue json_value = ExtractItems(json_obj, error);
if (!json_value.isArray()) {
emit SearchFinished(id, results);
return;
}
QJsonArray json_items = json_value.toArray();
if (json_items.isEmpty()) {
emit SearchFinished(id, results);
return;
}
for (const QJsonValue &value : json_items) {
if (!value.isObject()) {
Error("Invalid Json reply, item not a object.", value);
continue;
}
QJsonObject json_obj = value.toObject();
if (!json_obj.contains("artist") || !json_obj.contains("type") || !json_obj.contains("id") || !json_obj.contains("title") || !json_obj.contains("cover")) {
Error("Invalid Json reply, item missing id, type, album or cover.", json_obj);
continue;
}
QString album = json_obj["title"].toString();
QString cover = json_obj["cover"].toString();
QJsonValue json_value_artist = json_obj["artist"];
if (!json_value_artist.isObject()) {
Error("Invalid Json reply, item artist is not a object.", json_value_artist);
continue;
}
QJsonObject json_artist = json_value_artist.toObject();
if (!json_artist.contains("name")) {
Error("Invalid Json reply, item artist missing name.", json_artist);
continue;
}
QString artist = json_artist["name"].toString();
cover = cover.replace("-", "/");
QUrl cover_url (QString("%1/images/%2/%3.jpg").arg(kResourcesUrl).arg(cover).arg("1280x1280"));
CoverSearchResult cover_result;
cover_result.description = artist + " " + album;
cover_result.image_url = cover_url;
results << cover_result;
}
emit SearchFinished(id, results);
}
QString TidalCoverProvider::Error(QString error, QVariant debug) {
qLog(Error) << "Tidal:" << error;
if (debug.isValid()) qLog(Debug) << debug;
return error;
}

View File

@ -0,0 +1,83 @@
/*
* Strawberry Music Player
* Copyright 2018, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry 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 3 of the License, or
* (at your option) any later version.
*
* Strawberry 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 Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef TIDALCOVERPROVIDER_H
#define TIDALCOVERPROVIDER_H
#include "config.h"
#include <stdbool.h>
#include <QObject>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include "coverprovider.h"
#include "tidal/tidalservice.h"
class Application;
class TidalCoverProvider : public CoverProvider {
Q_OBJECT
public:
explicit TidalCoverProvider(Application *app, QObject *parent = nullptr);
void SetService(TidalService *service);
void ReloadSettings();
bool StartSearch(const QString &artist, const QString &album, int id);
void CancelSearch(int id);
private slots:
void HandleSearchReply(QNetworkReply *reply, int id);
private:
typedef QPair<QString, QString> Param;
static const char *kApiUrl;
static const char *kResourcesUrl;
static const char *kApiTokenB64;
static const int kLimit;
//QString username_;
//QString password_;
//QString session_id_;
//quint64 user_id_;
//QString country_code_;
#if 0
void LoadSessionID();
#endif
QNetworkReply *CreateRequest(const QString &ressource_name, const QList<Param> &params_supplied);
QByteArray GetReplyData(QNetworkReply *reply, QString &error);
QJsonObject ExtractJsonObj(QByteArray &data, QString &error);
QJsonValue ExtractItems(QByteArray &data, QString &error);
QJsonValue ExtractItems(QJsonObject &json_obj, QString &error);
QString Error(QString error, QVariant debug = QVariant());
TidalService *service_;
QNetworkAccessManager *network_;
};
#endif // TIDALCOVERPROVIDER_H

View File

@ -62,6 +62,9 @@ class TidalService : public InternetService {
const bool login_sent() { return login_sent_; }
const bool authenticated() { return (!session_id_.isEmpty() && !country_code_.isEmpty()); }
QString session_id() { return session_id_; }
QString country_code() { return country_code_; }
void GetStreamURL(const QUrl &url);
signals: