Add tidal support

This commit is contained in:
Jonas Kvinge
2018-08-09 18:10:03 +02:00
parent 26062bd07b
commit 820124f9e1
74 changed files with 5420 additions and 273 deletions

316
src/tidal/tidalsearch.cpp Normal file
View File

@@ -0,0 +1,316 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2010, David Sansome <me@davidsansome.com>
* 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 <QtGlobal>
#include <QObject>
#include <QList>
#include <QMap>
#include <QString>
#include <QStringList>
#include <QStringBuilder>
#include <QUrl>
#include <QImage>
#include <QPixmap>
#include <QIcon>
#include <QPainter>
#include <QTimerEvent>
#include <QSettings>
#include "core/application.h"
#include "core/logging.h"
#include "core/closure.h"
#include "core/iconloader.h"
#include "covermanager/albumcoverloader.h"
#include "internet/internetsongmimedata.h"
#include "playlist/songmimedata.h"
#include "tidalsearch.h"
#include "tidalservice.h"
#include "settings/tidalsettingspage.h"
const int TidalSearch::kDelayedSearchTimeoutMs = 200;
const int TidalSearch::kMaxResultsPerEmission = 1000;
const int TidalSearch::kArtHeight = 32;
TidalSearch::TidalSearch(Application *app, QObject *parent)
: QObject(parent),
app_(app),
service_(app->internet_model()->Service<TidalService>()),
name_("Tidal"),
id_("tidal"),
icon_(IconLoader::Load("tidal")),
searches_next_id_(1),
art_searches_next_id_(1) {
cover_loader_options_.desired_height_ = kArtHeight;
cover_loader_options_.pad_output_image_ = true;
cover_loader_options_.scale_output_image_ = true;
connect(app_->album_cover_loader(), SIGNAL(ImageLoaded(quint64, QImage)), SLOT(AlbumArtLoaded(quint64, QImage)));
connect(this, SIGNAL(SearchAsyncSig(int, QString, TidalSettingsPage::SearchBy)), this, SLOT(DoSearchAsync(int, QString, TidalSettingsPage::SearchBy)));
connect(this, SIGNAL(ResultsAvailable(int, TidalSearch::ResultList)), SLOT(ResultsAvailableSlot(int, TidalSearch::ResultList)));
connect(this, SIGNAL(ArtLoaded(int, QImage)), SLOT(ArtLoadedSlot(int, QImage)));
connect(service_, SIGNAL(SearchResults(int, SongList)), SLOT(SearchDone(int, SongList)));
connect(service_, SIGNAL(SearchError(int, QString)), SLOT(HandleError(int, QString)));
icon_as_image_ = QImage(icon_.pixmap(48, 48).toImage());
}
TidalSearch::~TidalSearch() {}
QStringList TidalSearch::TokenizeQuery(const QString &query) {
QStringList tokens(query.split(QRegExp("\\s+")));
for (QStringList::iterator it = tokens.begin(); it != tokens.end(); ++it) {
(*it).remove('(');
(*it).remove(')');
(*it).remove('"');
const int colon = (*it).indexOf(":");
if (colon != -1) {
(*it).remove(0, colon + 1);
}
}
return tokens;
}
bool TidalSearch::Matches(const QStringList &tokens, const QString &string) {
for (const QString &token : tokens) {
if (!string.contains(token, Qt::CaseInsensitive)) {
return false;
}
}
return true;
}
int TidalSearch::SearchAsync(const QString &query, TidalSettingsPage::SearchBy searchby) {
const int id = searches_next_id_++;
emit SearchAsyncSig(id, query, searchby);
return id;
}
void TidalSearch::SearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby) {
const int service_id = service_->Search(query, searchby);
pending_searches_[service_id] = PendingState(id, TokenizeQuery(query));
}
void TidalSearch::DoSearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby) {
int timer_id = startTimer(kDelayedSearchTimeoutMs);
delayed_searches_[timer_id].id_ = id;
delayed_searches_[timer_id].query_ = query;
delayed_searches_[timer_id].searchby_ = searchby;
}
void TidalSearch::SearchDone(int service_id, const SongList &songs) {
// Map back to the original id.
const PendingState state = pending_searches_.take(service_id);
const int search_id = state.orig_id_;
ResultList ret;
for (const Song &song : songs) {
Result result;
result.metadata_ = song;
ret << result;
}
emit ResultsAvailable(search_id, ret);
MaybeSearchFinished(search_id);
}
void TidalSearch::HandleError(const int id, const QString error) {
emit SearchError(id, error);
}
void TidalSearch::MaybeSearchFinished(int id) {
if (pending_searches_.keys(PendingState(id, QStringList())).isEmpty()) {
emit SearchFinished(id);
}
}
void TidalSearch::CancelSearch(int id) {
QMap<int, DelayedSearch>::iterator it;
for (it = delayed_searches_.begin(); it != delayed_searches_.end(); ++it) {
if (it.value().id_ == id) {
killTimer(it.key());
delayed_searches_.erase(it);
return;
}
}
}
void TidalSearch::timerEvent(QTimerEvent *e) {
QMap<int, DelayedSearch>::iterator it = delayed_searches_.find(e->timerId());
if (it != delayed_searches_.end()) {
SearchAsync(it.value().id_, it.value().query_, it.value().searchby_);
delayed_searches_.erase(it);
return;
}
QObject::timerEvent(e);
}
void TidalSearch::ResultsAvailableSlot(int id, TidalSearch::ResultList results) {
if (results.isEmpty()) return;
// Limit the number of results that are used from each emission.
if (results.count() > kMaxResultsPerEmission) {
TidalSearch::ResultList::iterator begin = results.begin();
std::advance(begin, kMaxResultsPerEmission);
results.erase(begin, results.end());
}
// Load cached pixmaps into the results
for (TidalSearch::ResultList::iterator it = results.begin(); it != results.end(); ++it) {
it->pixmap_cache_key_ = PixmapCacheKey(*it);
}
emit AddResults(id, results);
}
QString TidalSearch::PixmapCacheKey(const TidalSearch::Result &result) const {
return "tidal:" % result.metadata_.url().toString();
}
bool TidalSearch::FindCachedPixmap(const TidalSearch::Result &result, QPixmap *pixmap) const {
return pixmap_cache_.find(result.pixmap_cache_key_, pixmap);
}
void TidalSearch::LoadArtAsync(int id, const Result &result) {
emit ArtLoaded(id, QImage());
}
int TidalSearch::LoadArtAsync(const TidalSearch::Result &result) {
const int id = art_searches_next_id_++;
pending_art_searches_[id] = result.pixmap_cache_key_;
quint64 loader_id = app_->album_cover_loader()->LoadImageAsync(cover_loader_options_, result.metadata_);
cover_loader_tasks_[loader_id] = id;
return id;
}
void TidalSearch::ArtLoadedSlot(int id, const QImage &image) {
HandleLoadedArt(id, image);
}
void TidalSearch::AlbumArtLoaded(quint64 id, const QImage &image) {
if (!cover_loader_tasks_.contains(id)) return;
int orig_id = cover_loader_tasks_.take(id);
HandleLoadedArt(orig_id, image);
}
void TidalSearch::HandleLoadedArt(int id, const QImage &image) {
const QString key = pending_art_searches_.take(id);
QPixmap pixmap = QPixmap::fromImage(image);
pixmap_cache_.insert(key, pixmap);
emit ArtLoaded(id, pixmap);
}
QImage TidalSearch::ScaleAndPad(const QImage &image) {
if (image.isNull()) return QImage();
const QSize target_size = QSize(kArtHeight, kArtHeight);
if (image.size() == target_size) return image;
// Scale the image down
QImage copy;
copy = image.scaled(target_size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
// Pad the image to kHeight x kHeight
if (copy.size() == target_size) return copy;
QImage padded_image(kArtHeight, kArtHeight, QImage::Format_ARGB32);
padded_image.fill(0);
QPainter p(&padded_image);
p.drawImage((kArtHeight - copy.width()) / 2, (kArtHeight - copy.height()) / 2, copy);
p.end();
return padded_image;
}
MimeData *TidalSearch::LoadTracks(const ResultList &results) {
if (results.isEmpty()) {
return nullptr;
}
ResultList results_copy;
for (const Result &result : results) {
results_copy << result;
}
SongList songs;
for (const Result &result : results) {
songs << result.metadata_;
}
InternetSongMimeData *internet_song_mime_data = new InternetSongMimeData(service_);
internet_song_mime_data->songs = songs;
MimeData *mime_data = internet_song_mime_data;
QList<QUrl> urls;
for (const Result &result : results) {
urls << result.metadata_.url();
}
mime_data->setUrls(urls);
return mime_data;
}

157
src/tidal/tidalsearch.h Normal file
View File

@@ -0,0 +1,157 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2010, David Sansome <me@davidsansome.com>
* 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 TIDALSEARCH_H
#define TIDALSEARCH_H
#include "config.h"
#include <QObject>
#include <QFuture>
#include <QIcon>
#include <QMetaType>
#include <QPixmapCache>
#include "core/song.h"
#include "covermanager/albumcoverloaderoptions.h"
#include "settings/tidalsettingspage.h"
class Application;
class MimeData;
class AlbumCoverLoader;
class InternetService;
class TidalService;
class TidalSearch : public QObject {
Q_OBJECT
public:
TidalSearch(Application *app, QObject *parent = nullptr);
~TidalSearch();
struct Result {
Song metadata_;
QString pixmap_cache_key_;
};
typedef QList<Result> ResultList;
static const int kDelayedSearchTimeoutMs;
static const int kMaxResultsPerEmission;
Application *application() const { return app_; }
TidalService *service() const { return service_; }
int SearchAsync(const QString &query, TidalSettingsPage::SearchBy searchby);
int LoadArtAsync(const TidalSearch::Result &result);
void CancelSearch(int id);
void CancelArt(int id);
// Loads tracks for results that were previously emitted by ResultsAvailable.
// The implementation creates a SongMimeData with one Song for each Result.
MimeData *LoadTracks(const ResultList &results);
signals:
void SearchAsyncSig(int id, const QString &query, TidalSettingsPage::SearchBy searchby);
void ResultsAvailable(int id, const TidalSearch::ResultList &results);
void AddResults(int id, const TidalSearch::ResultList &results);
void SearchError(const int id, const QString error);
void SearchFinished(int id);
void ArtLoaded(int id, const QPixmap &pixmap);
void ArtLoaded(int id, const QImage &image);
protected:
struct PendingState {
PendingState() : orig_id_(-1) {}
PendingState(int orig_id, QStringList tokens)
: orig_id_(orig_id), tokens_(tokens) {}
int orig_id_;
QStringList tokens_;
bool operator<(const PendingState &b) const {
return orig_id_ < b.orig_id_;
}
bool operator==(const PendingState &b) const {
return orig_id_ == b.orig_id_;
}
};
void timerEvent(QTimerEvent *e);
// These functions treat queries in the same way as LibraryQuery.
// They're useful for figuring out whether you got a result because it matched in the song title or the artist/album name.
static QStringList TokenizeQuery(const QString &query);
static bool Matches(const QStringList &tokens, const QString &string);
private slots:
void DoSearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby);
void SearchDone(int id, const SongList &songs);
void HandleError(const int id, const QString error);
void ResultsAvailableSlot(int id, TidalSearch::ResultList results);
void ArtLoadedSlot(int id, const QImage &image);
void AlbumArtLoaded(quint64 id, const QImage &image);
private:
void SearchAsync(int id, const QString &query, TidalSettingsPage::SearchBy searchby);
void HandleLoadedArt(int id, const QImage &image);
bool FindCachedPixmap(const TidalSearch::Result &result, QPixmap *pixmap) const;
QString PixmapCacheKey(const TidalSearch::Result &result) const;
void LoadArtAsync(int id, const Result &result);
void MaybeSearchFinished(int id);
void ShowConfig() {}
static QImage ScaleAndPad(const QImage &image);
private:
struct DelayedSearch {
int id_;
QString query_;
TidalSettingsPage::SearchBy searchby_;
};
static const int kArtHeight;
Application *app_;
TidalService *service_;
QString name_;
QString id_;
QIcon icon_;
QImage icon_as_image_;
int searches_next_id_;
int art_searches_next_id_;
QMap<int, DelayedSearch> delayed_searches_;
QMap<int, QString> pending_art_searches_;
QPixmapCache pixmap_cache_;
AlbumCoverLoaderOptions cover_loader_options_;
QMap<quint64, int> cover_loader_tasks_;
QMap<int, PendingState> pending_searches_;
};
Q_DECLARE_METATYPE(TidalSearch::Result)
Q_DECLARE_METATYPE(TidalSearch::ResultList)
#endif // TIDALSEARCH_H

View File

@@ -0,0 +1,35 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2012, David Sansome <me@davidsansome.com>
*
* 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 <QPainter>
#include <QStyleOptionViewItem>
#include "tidalsearchitemdelegate.h"
#include "tidalsearchview.h"
TidalSearchItemDelegate::TidalSearchItemDelegate(TidalSearchView* view)
: CollectionItemDelegate(view), view_(view) {}
void TidalSearchItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
// Tell the view we painted this item so it can lazy load some art.
const_cast<TidalSearchView*>(view_)->LazyLoadArt(index);
CollectionItemDelegate::paint(painter, option, index);
}

View File

@@ -0,0 +1,41 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2012, David Sansome <me@davidsansome.com>
*
* 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 TIDALSEARCHITEMDELEGATE_H
#define TIDALSEARCHITEMDELEGATE_H
#include <QPainter>
#include <QStyleOptionViewItem>
#include "collection/collectionview.h"
class TidalSearchView;
class TidalSearchItemDelegate : public CollectionItemDelegate {
public:
TidalSearchItemDelegate(TidalSearchView *view);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const;
private:
TidalSearchView* view_;
};
#endif // TIDALSEARCHITEMDELEGATE_H

View File

@@ -0,0 +1,314 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2012, David Sansome <me@davidsansome.com>
*
* 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 <QObject>
#include <QStandardItem>
#include <QStandardItemModel>
#include <QList>
#include <QSet>
#include <QVariant>
#include <QString>
#include <QPixmap>
#include <QMimeData>
#include "core/mimedata.h"
#include "core/iconloader.h"
#include "tidalsearch.h"
#include "tidalsearchmodel.h"
TidalSearchModel::TidalSearchModel(TidalSearch *engine, QObject *parent)
: QStandardItemModel(parent),
engine_(engine),
proxy_(nullptr),
use_pretty_covers_(true),
artist_icon_(IconLoader::Load("guitar")) {
group_by_[0] = CollectionModel::GroupBy_Artist;
group_by_[1] = CollectionModel::GroupBy_Album;
group_by_[2] = CollectionModel::GroupBy_None;
no_cover_icon_ = QPixmap(":/pictures/noalbumart.png").scaled(CollectionModel::kPrettyCoverSize, CollectionModel::kPrettyCoverSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
album_icon_ = no_cover_icon_;
}
void TidalSearchModel::AddResults(const TidalSearch::ResultList &results) {
int sort_index = 0;
for (const TidalSearch::Result &result : results) {
QStandardItem *parent = invisibleRootItem();
// Find (or create) the container nodes for this result if we can.
ContainerKey key;
key.provider_index_ = sort_index;
parent = BuildContainers(result.metadata_, parent, &key);
// Create the item
QStandardItem *item = new QStandardItem;
item->setText(result.metadata_.TitleWithCompilationArtist());
item->setData(QVariant::fromValue(result), Role_Result);
item->setData(sort_index, Role_ProviderIndex);
parent->appendRow(item);
}
}
QStandardItem *TidalSearchModel::BuildContainers(const Song &s, QStandardItem *parent, ContainerKey *key, int level) {
if (level >= 3) {
return parent;
}
bool has_artist_icon = false;
bool has_album_icon = false;
QString display_text;
QString sort_text;
int unique_tag = -1;
int year = 0;
switch (group_by_[level]) {
case CollectionModel::GroupBy_Artist:
if (s.is_compilation()) {
display_text = tr("Various artists");
sort_text = "aaaaaa";
}
else {
display_text = CollectionModel::TextOrUnknown(s.artist());
sort_text = CollectionModel::SortTextForArtist(s.artist());
}
has_artist_icon = true;
break;
case CollectionModel::GroupBy_YearAlbum:
year = qMax(0, s.year());
display_text = CollectionModel::PrettyYearAlbum(year, s.album());
sort_text = CollectionModel::SortTextForNumber(year) + s.album();
unique_tag = s.album_id();
has_album_icon = true;
break;
case CollectionModel::GroupBy_OriginalYearAlbum:
year = qMax(0, s.effective_originalyear());
display_text = CollectionModel::PrettyYearAlbum(year, s.album());
sort_text = CollectionModel::SortTextForNumber(year) + s.album();
unique_tag = s.album_id();
has_album_icon = true;
break;
case CollectionModel::GroupBy_Year:
year = qMax(0, s.year());
display_text = QString::number(year);
sort_text = CollectionModel::SortTextForNumber(year) + " ";
break;
case CollectionModel::GroupBy_OriginalYear:
year = qMax(0, s.effective_originalyear());
display_text = QString::number(year);
sort_text = CollectionModel::SortTextForNumber(year) + " ";
break;
case CollectionModel::GroupBy_Composer:
display_text = s.composer();
case CollectionModel::GroupBy_Performer:
display_text = s.performer();
case CollectionModel::GroupBy_Disc:
display_text = s.disc();
case CollectionModel::GroupBy_Grouping:
display_text = s.grouping();
case CollectionModel::GroupBy_Genre:
if (display_text.isNull()) display_text = s.genre();
case CollectionModel::GroupBy_Album:
unique_tag = s.album_id();
if (display_text.isNull()) {
display_text = s.album();
}
// fallthrough
case CollectionModel::GroupBy_AlbumArtist:
if (display_text.isNull()) display_text = s.effective_albumartist();
display_text = CollectionModel::TextOrUnknown(display_text);
sort_text = CollectionModel::SortTextForArtist(display_text);
has_album_icon = true;
break;
case CollectionModel::GroupBy_FileType:
display_text = s.TextForFiletype();
sort_text = display_text;
break;
case CollectionModel::GroupBy_Bitrate:
display_text = QString(s.bitrate(), 1);
sort_text = display_text;
break;
case CollectionModel::GroupBy_Samplerate:
display_text = QString(s.samplerate(), 1);
sort_text = display_text;
break;
case CollectionModel::GroupBy_Bitdepth:
display_text = QString(s.bitdepth(), 1);
sort_text = display_text;
break;
case CollectionModel::GroupBy_None:
return parent;
}
// Find a container for this level
key->group_[level] = display_text + QString::number(unique_tag);
QStandardItem *container = containers_[*key];
if (!container) {
container = new QStandardItem(display_text);
container->setData(key->provider_index_, Role_ProviderIndex);
container->setData(sort_text, CollectionModel::Role_SortText);
container->setData(group_by_[level], CollectionModel::Role_ContainerType);
if (has_artist_icon) {
container->setIcon(artist_icon_);
}
else if (has_album_icon) {
if (use_pretty_covers_) {
container->setData(no_cover_icon_, Qt::DecorationRole);
}
else {
container->setIcon(album_icon_);
}
}
parent->appendRow(container);
containers_[*key] = container;
}
// Create the container for the next level.
return BuildContainers(s, container, key, level + 1);
}
void TidalSearchModel::Clear() {
containers_.clear();
clear();
}
TidalSearch::ResultList TidalSearchModel::GetChildResults(const QModelIndexList &indexes) const {
QList<QStandardItem*> items;
for (const QModelIndex &index : indexes) {
items << itemFromIndex(index);
}
return GetChildResults(items);
}
TidalSearch::ResultList TidalSearchModel::GetChildResults(const QList<QStandardItem*> &items) const {
TidalSearch::ResultList results;
QSet<const QStandardItem*> visited;
for (QStandardItem *item : items) {
GetChildResults(item, &results, &visited);
}
return results;
}
void TidalSearchModel::GetChildResults(const QStandardItem *item, TidalSearch::ResultList *results, QSet<const QStandardItem*> *visited) const {
if (visited->contains(item)) {
return;
}
visited->insert(item);
// Does this item have children?
if (item->rowCount()) {
const QModelIndex parent_proxy_index = proxy_->mapFromSource(item->index());
// Yes - visit all the children, but do so through the proxy so we get them
// in the right order.
for (int i = 0; i < item->rowCount(); ++i) {
const QModelIndex proxy_index = parent_proxy_index.child(i, 0);
const QModelIndex index = proxy_->mapToSource(proxy_index);
GetChildResults(itemFromIndex(index), results, visited);
}
}
else {
// No - maybe it's a song, add its result if valid
QVariant result = item->data(Role_Result);
if (result.isValid()) {
results->append(result.value<TidalSearch::Result>());
}
else {
// Maybe it's a provider then?
bool is_provider;
const int sort_index = item->data(Role_ProviderIndex).toInt(&is_provider);
if (is_provider) {
// Go through all the items (through the proxy to keep them ordered) and add the ones belonging to this provider to our list
for (int i = 0; i < proxy_->rowCount(invisibleRootItem()->index()); ++i) {
QModelIndex child_index = proxy_->index(i, 0, invisibleRootItem()->index());
const QStandardItem *child_item = itemFromIndex(proxy_->mapToSource(child_index));
if (child_item->data(Role_ProviderIndex).toInt() == sort_index) {
GetChildResults(child_item, results, visited);
}
}
}
}
}
}
QMimeData *TidalSearchModel::mimeData(const QModelIndexList &indexes) const {
return engine_->LoadTracks(GetChildResults(indexes));
}
namespace {
void GatherResults(const QStandardItem *parent, TidalSearch::ResultList *results) {
QVariant result_variant = parent->data(TidalSearchModel::Role_Result);
if (result_variant.isValid()) {
TidalSearch::Result result = result_variant.value<TidalSearch::Result>();
(*results).append(result);
}
for (int i = 0; i < parent->rowCount(); ++i) {
GatherResults(parent->child(i), results);
}
}
}
void TidalSearchModel::SetGroupBy(const CollectionModel::Grouping &grouping, bool regroup_now) {
const CollectionModel::Grouping old_group_by = group_by_;
group_by_ = grouping;
if (regroup_now && group_by_ != old_group_by) {
// Walk the tree gathering the results we have already
TidalSearch::ResultList results;
GatherResults(invisibleRootItem(), &results);
// Reset the model and re-add all the results using the new grouping.
Clear();
AddResults(results);
}
}

View File

@@ -0,0 +1,109 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2012, David Sansome <me@davidsansome.com>
*
* 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 TIDALSEARCHMODEL_H
#define TIDALSEARCHMODEL_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QMimeData>
#include <QStandardItemModel>
#include <QStandardItem>
#include <QSortFilterProxyModel>
#include <QMap>
#include <QSet>
#include <QList>
#include <QString>
#include <QStringList>
#include <QIcon>
#include <QPixmap>
#include "collection/collectionmodel.h"
#include "tidalsearch.h"
class TidalSearchModel : public QStandardItemModel {
Q_OBJECT
public:
TidalSearchModel(TidalSearch *engine, QObject *parent = nullptr);
enum Role {
Role_Result = CollectionModel::LastRole,
Role_LazyLoadingArt,
Role_ProviderIndex,
LastRole
};
struct ContainerKey {
int provider_index_;
QString group_[3];
};
void set_proxy(QSortFilterProxyModel *proxy) { proxy_ = proxy; }
void set_use_pretty_covers(bool pretty) { use_pretty_covers_ = pretty; }
void SetGroupBy(const CollectionModel::Grouping &grouping, bool regroup_now);
void Clear();
TidalSearch::ResultList GetChildResults(const QModelIndexList &indexes) const;
TidalSearch::ResultList GetChildResults(const QList<QStandardItem*> &items) const;
QMimeData *mimeData(const QModelIndexList &indexes) const;
public slots:
void AddResults(const TidalSearch::ResultList &results);
private:
QStandardItem *BuildContainers(const Song &metadata, QStandardItem *parent, ContainerKey *key, int level = 0);
void GetChildResults(const QStandardItem *item, TidalSearch::ResultList *results, QSet<const QStandardItem*> *visited) const;
private:
TidalSearch *engine_;
QSortFilterProxyModel *proxy_;
bool use_pretty_covers_;
QIcon artist_icon_;
QPixmap no_cover_icon_;
QIcon album_icon_;
CollectionModel::Grouping group_by_;
QMap<ContainerKey, QStandardItem*> containers_;
};
inline uint qHash(const TidalSearchModel::ContainerKey &key) {
return qHash(key.provider_index_) ^ qHash(key.group_[0]) ^ qHash(key.group_[1]) ^ qHash(key.group_[2]);
}
inline bool operator<(const TidalSearchModel::ContainerKey &left, const TidalSearchModel::ContainerKey &right) {
#define CMP(field) \
if (left.field < right.field) return true; \
if (left.field > right.field) return false
CMP(provider_index_);
CMP(group_[0]);
CMP(group_[1]);
CMP(group_[2]);
return false;
#undef CMP
}
#endif // TIDALSEARCHMODEL_H

View File

@@ -0,0 +1,79 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2010, David Sansome <me@davidsansome.com>
*
* 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 <QObject>
#include <QSortFilterProxyModel>
#include <QString>
#include "core/logging.h"
#include "tidalsearchmodel.h"
#include "tidalsearchsortmodel.h"
TidalSearchSortModel::TidalSearchSortModel(QObject *parent)
: QSortFilterProxyModel(parent) {}
bool TidalSearchSortModel::lessThan(const QModelIndex &left, const QModelIndex &right) const {
// Compare the provider sort index first.
const int index_left = left.data(TidalSearchModel::Role_ProviderIndex).toInt();
const int index_right = right.data(TidalSearchModel::Role_ProviderIndex).toInt();
if (index_left < index_right) return true;
if (index_left > index_right) return false;
// Dividers always go first
if (left.data(CollectionModel::Role_IsDivider).toBool()) return true;
if (right.data(CollectionModel::Role_IsDivider).toBool()) return false;
// Containers go before songs if they're at the same level
const bool left_is_container = left.data(CollectionModel::Role_ContainerType).isValid();
const bool right_is_container = right.data(CollectionModel::Role_ContainerType).isValid();
if (left_is_container && !right_is_container) return true;
if (right_is_container && !left_is_container) return false;
// Containers get sorted on their sort text.
if (left_is_container) {
return QString::localeAwareCompare(left.data(CollectionModel::Role_SortText).toString(), right.data(CollectionModel::Role_SortText).toString()) < 0;
}
// Otherwise we're comparing songs. Sort by disc, track, then title.
const TidalSearch::Result r1 = left.data(TidalSearchModel::Role_Result).value<TidalSearch::Result>();
const TidalSearch::Result r2 = right.data(TidalSearchModel::Role_Result).value<TidalSearch::Result>();
#define CompareInt(field) \
if (r1.metadata_.field() < r2.metadata_.field()) return true; \
if (r1.metadata_.field() > r2.metadata_.field()) return false
int ret = 0;
#define CompareString(field) \
ret = QString::localeAwareCompare(r1.metadata_.field(), r2.metadata_.field()); \
if (ret < 0) return true; \
if (ret > 0) return false
CompareInt(disc);
CompareInt(track);
CompareString(title);
return false;
#undef CompareInt
#undef CompareString
}

View File

@@ -0,0 +1,35 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2010, David Sansome <me@davidsansome.com>
*
* 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 TIDALSEARCHSORTMODEL_H
#define TIDALSEARCHSORTMODEL_H
#include <QObject>
#include <QSortFilterProxyModel>
class TidalSearchSortModel : public QSortFilterProxyModel {
public:
TidalSearchSortModel(QObject *parent = nullptr);
protected:
bool lessThan(const QModelIndex &left, const QModelIndex &right) const;
};
#endif // TIDALSEARCHSORTMODEL_H

View File

@@ -0,0 +1,544 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2012, David Sansome <me@davidsansome.com>
* 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 <functional>
#include <QtGlobal>
#include <QWidget>
#include <QTimer>
#include <QList>
#include <QString>
#include <QStringList>
#include <QPixmap>
#include <QPalette>
#include <QColor>
#include <QFont>
#include <QMenu>
#include <QSortFilterProxyModel>
#include <QStandardItem>
#include <QSettings>
#include <QAction>
#include <QtEvents>
#include "core/application.h"
#include "core/logging.h"
#include "core/mimedata.h"
#include "core/timeconstants.h"
#include "core/iconloader.h"
#include "internet/internetsongmimedata.h"
#include "collection/collectionfilterwidget.h"
#include "collection/collectionmodel.h"
#include "collection/groupbydialog.h"
#include "playlist/songmimedata.h"
#include "tidalsearch.h"
#include "tidalsearchitemdelegate.h"
#include "tidalsearchmodel.h"
#include "tidalsearchsortmodel.h"
#include "tidalsearchview.h"
#include "ui_tidalsearchview.h"
#include "settings/tidalsettingspage.h"
using std::placeholders::_1;
using std::placeholders::_2;
using std::swap;
const int TidalSearchView::kSwapModelsTimeoutMsec = 250;
TidalSearchView::TidalSearchView(Application *app, QWidget *parent)
: QWidget(parent),
app_(app),
engine_(app_->tidal_search()),
ui_(new Ui_TidalSearchView),
context_menu_(nullptr),
last_search_id_(0),
front_model_(new TidalSearchModel(engine_, this)),
back_model_(new TidalSearchModel(engine_, this)),
current_model_(front_model_),
front_proxy_(new TidalSearchSortModel(this)),
back_proxy_(new TidalSearchSortModel(this)),
current_proxy_(front_proxy_),
swap_models_timer_(new QTimer(this)),
search_icon_(IconLoader::Load("search")),
warning_icon_(IconLoader::Load("dialog-warning")),
error_(false) {
ui_->setupUi(this);
front_model_->set_proxy(front_proxy_);
back_model_->set_proxy(back_proxy_);
ui_->search->installEventFilter(this);
ui_->results_stack->installEventFilter(this);
ui_->settings->setIcon(IconLoader::Load("configure"));
// Must be a queued connection to ensure the TidalSearch handles it first.
connect(app_, SIGNAL(SettingsChanged()), SLOT(ReloadSettings()), Qt::QueuedConnection);
connect(ui_->search, SIGNAL(textChanged(QString)), SLOT(TextEdited(QString)));
connect(ui_->results, SIGNAL(AddToPlaylistSignal(QMimeData*)), SIGNAL(AddToPlaylist(QMimeData*)));
connect(ui_->results, SIGNAL(FocusOnFilterSignal(QKeyEvent*)), SLOT(FocusOnFilter(QKeyEvent*)));
// Set the appearance of the results list
ui_->results->setItemDelegate(new TidalSearchItemDelegate(this));
ui_->results->setAttribute(Qt::WA_MacShowFocusRect, false);
ui_->results->setStyleSheet("QTreeView::item{padding-top:1px;}");
// Show the help page initially
ui_->results_stack->setCurrentWidget(ui_->help_page);
ui_->help_frame->setBackgroundRole(QPalette::Base);
// Set the colour of the help text to the disabled window text colour
QPalette help_palette = ui_->label_helptext->palette();
const QColor help_color = help_palette.color(QPalette::Disabled, QPalette::WindowText);
help_palette.setColor(QPalette::Normal, QPalette::WindowText, help_color);
help_palette.setColor(QPalette::Inactive, QPalette::WindowText, help_color);
ui_->label_helptext->setPalette(help_palette);
// Make it bold
QFont help_font = ui_->label_helptext->font();
help_font.setBold(true);
ui_->label_helptext->setFont(help_font);
// Set up the sorting proxy model
front_proxy_->setSourceModel(front_model_);
front_proxy_->setDynamicSortFilter(true);
front_proxy_->sort(0);
back_proxy_->setSourceModel(back_model_);
back_proxy_->setDynamicSortFilter(true);
back_proxy_->sort(0);
swap_models_timer_->setSingleShot(true);
swap_models_timer_->setInterval(kSwapModelsTimeoutMsec);
connect(swap_models_timer_, SIGNAL(timeout()), SLOT(SwapModels()));
// Add actions to the settings menu
group_by_actions_ = CollectionFilterWidget::CreateGroupByActions(this);
QMenu *settings_menu = new QMenu(this);
settings_menu->addActions(group_by_actions_->actions());
settings_menu->addSeparator();
settings_menu->addAction(IconLoader::Load("configure"), tr("Configure Tidal..."), this, SLOT(OpenSettingsDialog()));
ui_->settings->setMenu(settings_menu);
connect(ui_->radiobutton_searchbyalbums, SIGNAL(clicked(bool)), SLOT(SearchByAlbumsClicked(bool)));
connect(ui_->radiobutton_searchbysongs, SIGNAL(clicked(bool)), SLOT(SearchBySongsClicked(bool)));
connect(group_by_actions_, SIGNAL(triggered(QAction*)), SLOT(GroupByClicked(QAction*)));
// These have to be queued connections because they may get emitted before our call to Search() (or whatever) returns and we add the ID to the map.
connect(engine_, SIGNAL(AddResults(int, TidalSearch::ResultList)), SLOT(AddResults(int, TidalSearch::ResultList)), Qt::QueuedConnection);
connect(engine_, SIGNAL(SearchError(int, QString)), SLOT(SearchError(int, QString)), Qt::QueuedConnection);
connect(engine_, SIGNAL(ArtLoaded(int, QPixmap)), SLOT(ArtLoaded(int, QPixmap)), Qt::QueuedConnection);
ReloadSettings();
}
TidalSearchView::~TidalSearchView() { delete ui_; }
void TidalSearchView::ReloadSettings() {
QSettings s;
// Collection settings
s.beginGroup(TidalSettingsPage::kSettingsGroup);
const bool pretty = s.value("pretty_covers", true).toBool();
front_model_->set_use_pretty_covers(pretty);
back_model_->set_use_pretty_covers(pretty);
s.endGroup();
// Tidal search settings
s.beginGroup(TidalSettingsPage::kSettingsGroup);
searchby_ = TidalSettingsPage::SearchBy(s.value("searchby", int(TidalSettingsPage::SearchBy_Songs)).toInt());
switch (searchby_) {
case TidalSettingsPage::SearchBy_Songs:
ui_->radiobutton_searchbysongs->setChecked(true);
break;
case TidalSettingsPage::SearchBy_Albums:
ui_->radiobutton_searchbyalbums->setChecked(true);
break;
}
SetGroupBy(CollectionModel::Grouping(
CollectionModel::GroupBy(s.value("group_by1", int(CollectionModel::GroupBy_Artist)).toInt()),
CollectionModel::GroupBy(s.value("group_by2", int(CollectionModel::GroupBy_Album)).toInt()),
CollectionModel::GroupBy(s.value("group_by3", int(CollectionModel::GroupBy_None)).toInt())));
s.endGroup();
}
void TidalSearchView::StartSearch(const QString &query) {
ui_->search->setText(query);
TextEdited(query);
// Swap models immediately
swap_models_timer_->stop();
SwapModels();
}
void TidalSearchView::TextEdited(const QString &text) {
const QString trimmed(text.trimmed());
error_ = false;
// Add results to the back model, switch models after some delay.
back_model_->Clear();
current_model_ = back_model_;
current_proxy_ = back_proxy_;
swap_models_timer_->start();
// Cancel the last search (if any) and start the new one.
engine_->CancelSearch(last_search_id_);
// If text query is empty, don't start a new search
if (trimmed.isEmpty()) {
last_search_id_ = -1;
ui_->label_helptext->setText("Enter search terms above to find music");
}
else {
last_search_id_ = engine_->SearchAsync(trimmed, searchby_);
}
}
void TidalSearchView::AddResults(int id, const TidalSearch::ResultList &results) {
if (id != last_search_id_) return;
if (results.isEmpty()) return;
current_model_->AddResults(results);
}
void TidalSearchView::SearchError(const int id, const QString error) {
error_ = true;
ui_->label_helptext->setText(error);
ui_->results_stack->setCurrentWidget(ui_->help_page);
}
void TidalSearchView::SwapModels() {
art_requests_.clear();
std::swap(front_model_, back_model_);
std::swap(front_proxy_, back_proxy_);
ui_->results->setModel(front_proxy_);
if (ui_->search->text().trimmed().isEmpty() || error_) {
ui_->results_stack->setCurrentWidget(ui_->help_page);
}
else {
ui_->results_stack->setCurrentWidget(ui_->results_page);
}
}
void TidalSearchView::LazyLoadArt(const QModelIndex &proxy_index) {
if (!proxy_index.isValid() || proxy_index.model() != front_proxy_) {
return;
}
// Already loading art for this item?
if (proxy_index.data(TidalSearchModel::Role_LazyLoadingArt).isValid()) {
return;
}
// Should we even load art at all?
if (!app_->collection_model()->use_pretty_covers()) {
return;
}
// Is this an album?
const CollectionModel::GroupBy container_type = CollectionModel::GroupBy(proxy_index.data(CollectionModel::Role_ContainerType).toInt());
if (container_type != CollectionModel::GroupBy_Album &&
container_type != CollectionModel::GroupBy_AlbumArtist &&
container_type != CollectionModel::GroupBy_YearAlbum &&
container_type != CollectionModel::GroupBy_OriginalYearAlbum) {
return;
}
// Mark the item as loading art
const QModelIndex source_index = front_proxy_->mapToSource(proxy_index);
QStandardItem *item = front_model_->itemFromIndex(source_index);
item->setData(true, TidalSearchModel::Role_LazyLoadingArt);
// Walk down the item's children until we find a track
while (item->rowCount()) {
item = item->child(0);
}
// Get the track's Result
const TidalSearch::Result result = item->data(TidalSearchModel::Role_Result).value<TidalSearch::Result>();
// Load the art.
int id = engine_->LoadArtAsync(result);
art_requests_[id] = source_index;
}
void TidalSearchView::ArtLoaded(int id, const QPixmap &pixmap) {
if (!art_requests_.contains(id)) return;
QModelIndex index = art_requests_.take(id);
if (!pixmap.isNull()) {
front_model_->itemFromIndex(index)->setData(pixmap, Qt::DecorationRole);
}
}
MimeData *TidalSearchView::SelectedMimeData() {
if (!ui_->results->selectionModel()) return nullptr;
// Get all selected model indexes
QModelIndexList indexes = ui_->results->selectionModel()->selectedRows();
if (indexes.isEmpty()) {
// There's nothing selected - take the first thing in the model that isn't a divider.
for (int i = 0; i < front_proxy_->rowCount(); ++i) {
QModelIndex index = front_proxy_->index(i, 0);
if (!index.data(CollectionModel::Role_IsDivider).toBool()) {
indexes << index;
ui_->results->setCurrentIndex(index);
break;
}
}
}
// Still got nothing? Give up.
if (indexes.isEmpty()) {
return nullptr;
}
// Get items for these indexes
QList<QStandardItem*> items;
for (const QModelIndex &index : indexes) {
items << (front_model_->itemFromIndex(front_proxy_->mapToSource(index)));
}
// Get a MimeData for these items
return engine_->LoadTracks(front_model_->GetChildResults(items));
}
bool TidalSearchView::eventFilter(QObject *object, QEvent *event) {
if (object == ui_->search && event->type() == QEvent::KeyRelease) {
if (SearchKeyEvent(static_cast<QKeyEvent*>(event))) {
return true;
}
}
else if (object == ui_->results_stack && event->type() == QEvent::ContextMenu) {
if (ResultsContextMenuEvent(static_cast<QContextMenuEvent*>(event))) {
return true;
}
}
return QWidget::eventFilter(object, event);
}
bool TidalSearchView::SearchKeyEvent(QKeyEvent *event) {
switch (event->key()) {
case Qt::Key_Up:
ui_->results->UpAndFocus();
break;
case Qt::Key_Down:
ui_->results->DownAndFocus();
break;
case Qt::Key_Escape:
ui_->search->clear();
break;
case Qt::Key_Return:
AddSelectedToPlaylist();
break;
default:
return false;
}
event->accept();
return true;
}
bool TidalSearchView::ResultsContextMenuEvent(QContextMenuEvent *event) {
context_menu_ = new QMenu(this);
context_actions_ << context_menu_->addAction( IconLoader::Load("media-playback-start"), tr("Append to current playlist"), this, SLOT(AddSelectedToPlaylist()));
context_actions_ << context_menu_->addAction( IconLoader::Load("media-playback-start"), tr("Replace current playlist"), this, SLOT(LoadSelected()));
context_actions_ << context_menu_->addAction( IconLoader::Load("document-new"), tr("Open in new playlist"), this, SLOT(OpenSelectedInNewPlaylist()));
context_menu_->addSeparator();
context_actions_ << context_menu_->addAction(IconLoader::Load("go-next"), tr("Queue track"), this, SLOT(AddSelectedToPlaylistEnqueue()));
context_menu_->addSeparator();
if (ui_->results->selectionModel() && ui_->results->selectionModel()->selectedRows().length() == 1) {
context_actions_ << context_menu_->addAction(IconLoader::Load("system-search"), tr("Search for this"), this, SLOT(SearchForThis()));
}
context_menu_->addSeparator();
context_menu_->addMenu(tr("Group by"))->addActions(group_by_actions_->actions());
context_menu_->addAction(IconLoader::Load("configure"), tr("Configure Tidal..."), this, SLOT(OpenSettingsDialog()));
const bool enable_context_actions = ui_->results->selectionModel() && ui_->results->selectionModel()->hasSelection();
for (QAction *action : context_actions_) {
action->setEnabled(enable_context_actions);
}
context_menu_->popup(event->globalPos());
return true;
}
void TidalSearchView::AddSelectedToPlaylist() {
emit AddToPlaylist(SelectedMimeData());
}
void TidalSearchView::LoadSelected() {
MimeData *data = SelectedMimeData();
if (!data) return;
data->clear_first_ = true;
emit AddToPlaylist(data);
}
void TidalSearchView::AddSelectedToPlaylistEnqueue() {
MimeData *data = SelectedMimeData();
if (!data) return;
data->enqueue_now_ = true;
emit AddToPlaylist(data);
}
void TidalSearchView::OpenSelectedInNewPlaylist() {
MimeData *data = SelectedMimeData();
if (!data) return;
data->open_in_new_playlist_ = true;
emit AddToPlaylist(data);
}
void TidalSearchView::SearchForThis() {
StartSearch(ui_->results->selectionModel()->selectedRows().first().data().toString());
}
void TidalSearchView::showEvent(QShowEvent *e) {
QWidget::showEvent(e);
FocusSearchField();
}
void TidalSearchView::FocusSearchField() {
ui_->search->setFocus();
ui_->search->selectAll();
}
void TidalSearchView::hideEvent(QHideEvent *e) {
QWidget::hideEvent(e);
}
void TidalSearchView::FocusOnFilter(QKeyEvent *event) {
ui_->search->setFocus();
QApplication::sendEvent(ui_->search, event);
}
void TidalSearchView::OpenSettingsDialog() {
app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal);
}
void TidalSearchView::GroupByClicked(QAction *action) {
if (action->property("group_by").isNull()) {
if (!group_by_dialog_) {
group_by_dialog_.reset(new GroupByDialog);
connect(group_by_dialog_.data(), SIGNAL(Accepted(CollectionModel::Grouping)), SLOT(SetGroupBy(CollectionModel::Grouping)));
}
group_by_dialog_->show();
return;
}
SetGroupBy(action->property("group_by").value<CollectionModel::Grouping>());
}
void TidalSearchView::SetGroupBy(const CollectionModel::Grouping &g) {
// Clear requests: changing "group by" on the models will cause all the items to be removed/added again,
// so all the QModelIndex here will become invalid. New requests will be created for those
// songs when they will be displayed again anyway (when TidalSearchItemDelegate::paint will call LazyLoadArt)
art_requests_.clear();
// Update the models
front_model_->SetGroupBy(g, true);
back_model_->SetGroupBy(g, false);
// Save the setting
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
s.setValue("group_by1", int(g.first));
s.setValue("group_by2", int(g.second));
s.setValue("group_by3", int(g.third));
s.endGroup();
// Make sure the correct action is checked.
for (QAction *action : group_by_actions_->actions()) {
if (action->property("group_by").isNull()) continue;
if (g == action->property("group_by").value<CollectionModel::Grouping>()) {
action->setChecked(true);
return;
}
}
// Check the advanced action
group_by_actions_->actions().last()->setChecked(true);
}
void TidalSearchView::SearchBySongsClicked(bool checked) {
SetSearchBy(TidalSettingsPage::SearchBy_Songs);
}
void TidalSearchView::SearchByAlbumsClicked(bool checked) {
SetSearchBy(TidalSettingsPage::SearchBy_Albums);
}
void TidalSearchView::SetSearchBy(TidalSettingsPage::SearchBy searchby) {
searchby_ = searchby;
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
s.setValue("searchby", int(searchby));
s.endGroup();
TextEdited(ui_->search->text());
}

139
src/tidal/tidalsearchview.h Normal file
View File

@@ -0,0 +1,139 @@
/*
* Strawberry Music Player
* This code was part of Clementine (GlobalSearch)
* Copyright 2012, David Sansome <me@davidsansome.com>
* 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 TIDALSEARCHVIEW_H
#define TIDALSEARCHVIEW_H
#include "config.h"
#include <QWidget>
#include <QObject>
#include <QTimer>
#include <QMap>
#include <QList>
#include <QString>
#include <QIcon>
#include <QPixmap>
#include <QMimeData>
#include <QMenu>
#include <QSortFilterProxyModel>
#include <QAction>
#include <QActionGroup>
#include <QtEvents>
#include "collection/collectionmodel.h"
#include "settings/settingsdialog.h"
#include "playlist/playlistmanager.h"
#include "tidalsearch.h"
#include "settings/tidalsettingspage.h"
class Application;
class GroupByDialog;
class TidalSearchModel;
class Ui_TidalSearchView;
class TidalSearchView : public QWidget {
Q_OBJECT
public:
TidalSearchView(Application *app, QWidget *parent = nullptr);
~TidalSearchView();
static const int kSwapModelsTimeoutMsec;
void LazyLoadArt(const QModelIndex &index);
void showEvent(QShowEvent *e);
void hideEvent(QHideEvent *e);
bool eventFilter(QObject *object, QEvent *event);
public slots:
void ReloadSettings();
void StartSearch(const QString &query);
void FocusSearchField();
void OpenSettingsDialog();
signals:
void AddToPlaylist(QMimeData *data);
private slots:
void SwapModels();
void TextEdited(const QString &text);
void AddResults(int id, const TidalSearch::ResultList &results);
void SearchError(const int id, const QString error);
void ArtLoaded(int id, const QPixmap &pixmap);
void FocusOnFilter(QKeyEvent *event);
void AddSelectedToPlaylist();
void LoadSelected();
void OpenSelectedInNewPlaylist();
void AddSelectedToPlaylistEnqueue();
void SearchForThis();
void SearchBySongsClicked(bool);
void SearchByAlbumsClicked(bool);
void GroupByClicked(QAction *action);
void SetSearchBy(TidalSettingsPage::SearchBy searchby);
void SetGroupBy(const CollectionModel::Grouping &g);
private:
MimeData *SelectedMimeData();
bool SearchKeyEvent(QKeyEvent *event);
bool ResultsContextMenuEvent(QContextMenuEvent *event);
Application *app_;
TidalSearch *engine_;
Ui_TidalSearchView *ui_;
QScopedPointer<GroupByDialog> group_by_dialog_;
QMenu *context_menu_;
QList<QAction*> context_actions_;
QActionGroup *group_by_actions_;
int last_search_id_;
// Like graphics APIs have a front buffer and a back buffer, there's a front model and a back model
// The front model is the one that's shown in the UI and the back model is the one that lies in wait.
// current_model_ will point to either the front or the back model.
TidalSearchModel *front_model_;
TidalSearchModel *back_model_;
TidalSearchModel *current_model_;
QSortFilterProxyModel *front_proxy_;
QSortFilterProxyModel *back_proxy_;
QSortFilterProxyModel *current_proxy_;
QMap<int, QModelIndex> art_requests_;
QTimer *swap_models_timer_;
QIcon search_icon_;
QIcon warning_icon_;
TidalSettingsPage::SearchBy searchby_;
bool error_;
};
#endif // TIDALSEARCHVIEW_H

View File

@@ -0,0 +1,259 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TidalSearchView</class>
<widget class="QWidget" name="TidalSearchView">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>437</width>
<height>633</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="widget_search" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>0</number>
</property>
<item>
<layout class="QVBoxLayout" name="layout_top" stretch="0,0">
<item>
<layout class="QHBoxLayout" name="layout_search">
<item>
<widget class="QSearchField" name="search" native="true">
<property name="placeholderText" stdset="0">
<string>Search for anything</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="settings">
<property name="minimumSize">
<size>
<width>20</width>
<height>0</height>
</size>
</property>
<property name="popupMode">
<enum>QToolButton::InstantPopup</enum>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="layout_searchby">
<property name="sizeConstraint">
<enum>QLayout::SetFixedSize</enum>
</property>
<item>
<widget class="QLabel" name="label_searchby">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>Search by</string>
</property>
<property name="margin">
<number>10</number>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="radiobutton_searchbyalbums">
<property name="text">
<string>albu&amp;ms</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="radiobutton_searchbysongs">
<property name="text">
<string>songs</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QStackedWidget" name="results_stack">
<property name="currentIndex">
<number>1</number>
</property>
<widget class="QWidget" name="results_page">
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="AutoExpandingTreeView" name="results">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::DragOnly</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="allColumnsShowFocus">
<bool>true</bool>
</property>
<attribute name="headerVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="help_page">
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QScrollArea" name="help_frame">
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="help_frame_contents">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>435</width>
<height>533</height>
</rect>
</property>
<widget class="QWidget" name="widget" native="true">
<property name="geometry">
<rect>
<x>9</x>
<y>109</y>
<width>420</width>
<height>100</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="leftMargin">
<number>32</number>
</property>
<property name="topMargin">
<number>16</number>
</property>
<property name="rightMargin">
<number>32</number>
</property>
<property name="bottomMargin">
<number>64</number>
</property>
<item>
<widget class="QLabel" name="label_helptext">
<property name="minimumSize">
<size>
<width>0</width>
<height>80</height>
</size>
</property>
<property name="text">
<string>Enter search terms above to find music</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>QSearchField</class>
<extends>QWidget</extends>
<header>3rdparty/qocoa/qsearchfield.h</header>
</customwidget>
<customwidget>
<class>AutoExpandingTreeView</class>
<extends>QTreeView</extends>
<header>widgets/autoexpandingtreeview.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

832
src/tidal/tidalservice.cpp Normal file
View File

@@ -0,0 +1,832 @@
/*
* 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 <QObject>
#include <QByteArray>
#include <QList>
#include <QVector>
#include <QPair>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QTimer>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include <QMenu>
#include <QSettings>
#include "core/application.h"
#include "core/closure.h"
#include "core/logging.h"
#include "core/mergedproxymodel.h"
#include "core/network.h"
#include "core/song.h"
#include "core/iconloader.h"
#include "core/taskmanager.h"
#include "core/timeconstants.h"
#include "core/utilities.h"
#include "internet/internetmodel.h"
#include "tidalservice.h"
#include "tidalsearch.h"
#include "settings/tidalsettingspage.h"
const char *TidalService::kServiceName = "Tidal";
const char *TidalService::kApiUrl = "https://listen.tidal.com/v1";
const char *TidalService::kAuthUrl = "https://listen.tidal.com/v1/login/username";
const char *TidalService::kResourcesUrl = "http://resources.tidal.com";
const char *TidalService::kApiToken = "P5Xbeo5LFvESeDy6";
const int TidalService::kSearchDelayMsec = 1500;
const int TidalService::kSearchAlbumsLimit = 40;
const int TidalService::kSearchTracksLimit = 10;
typedef QPair<QString, QString> Param;
TidalService::TidalService(Application *app, InternetModel *parent)
: InternetService(kServiceName, app, parent, parent),
network_(new NetworkAccessManager(this)),
search_delay_(new QTimer(this)),
pending_search_id_(0),
next_pending_search_id_(1),
search_requests_(0),
login_sent_(false) {
search_delay_->setInterval(kSearchDelayMsec);
search_delay_->setSingleShot(true);
connect(search_delay_, SIGNAL(timeout()), SLOT(StartSearch()));
ReloadSettings();
LoadSessionID();
}
TidalService::~TidalService() {}
void TidalService::ShowConfig() {
app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal);
}
void TidalService::ReloadSettings() {
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
username_ = s.value("username").toString();
password_ = s.value("password").toString();
quality_ = s.value("quality").toString();
s.endGroup();
}
void TidalService::LoadSessionID() {
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
if (!s.contains("user_id") ||!s.contains("session_id") || !s.contains("country_code")) return;
session_id_ = s.value("session_id").toString();
user_id_ = s.value("user_id").toInt();
country_code_ = s.value("country_code").toString();
s.endGroup();
}
void TidalService::Login(const QString &username, const QString &password) {
Login(nullptr, username, password);
}
void TidalService::Login(TidalSearchContext *search_ctx, const QString &username, const QString &password) {
login_sent_ = true;
int id = 0;
if (search_ctx) {
search_ctx->login_sent = true;
search_ctx->login_attempts++;
id = search_ctx->id;
}
typedef QPair<QString, QString> Arg;
typedef QList<Arg> ArgList;
typedef QPair<QByteArray, QByteArray> EncodedArg;
typedef QList<EncodedArg> EncodedArgList;
ArgList args = ArgList() <<Arg("token", kApiToken) << Arg("username", username) << Arg("password", password) << Arg("clientVersion", "2.2.1--7");
QStringList query_items;
QUrlQuery url_query;
for (const Arg &arg : args) {
EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second));
query_items << QString(encoded_arg.first + "=" + encoded_arg.second);
url_query.addQueryItem(encoded_arg.first, encoded_arg.second);
}
QUrl url(kAuthUrl);
QNetworkRequest req(url);
req.setRawHeader("Origin", "http://listen.tidal.com");
QNetworkReply *reply = network_->post(req, url_query.toString(QUrl::FullyEncoded).toUtf8());
NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*, int)), reply, id);
}
void TidalService::HandleAuthReply(QNetworkReply *reply, int id) {
reply->deleteLater();
login_sent_ = false;
TidalSearchContext *search_ctx(nullptr);
if (id != 0 && requests_search_.contains(id)) {
search_ctx = requests_search_.value(id);
search_ctx->login_sent = false;
}
//int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError) {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
else {
// See if there is Json data containing "userMessage" - then use that instead.
QByteArray data(reply->readAll());
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
QString failure_reason;
if (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("userMessage")) {
failure_reason = QString("Authentication failure: %1").arg(json_obj["userMessage"].toString());
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
}
QByteArray data(reply->readAll());
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
QString failure_reason("Authentication reply from server missing Json data.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
if (json_doc.isNull() || json_doc.isEmpty()) {
QString failure_reason("Authentication reply from server has empty Json document.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
if (!json_doc.isObject()) {
QString failure_reason("Authentication reply from server has Json document that is not an object.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
QString failure_reason("Authentication reply from server has empty Json object.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
if ( !json_obj.contains("userId") || !json_obj.contains("sessionId") || !json_obj.contains("countryCode") ) {
QString failure_reason = tr("Authentication reply from server is missing userId, sessionId or countryCode");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
country_code_ = json_obj["countryCode"].toString();
session_id_ = json_obj["sessionId"].toString();
user_id_ = json_obj["userId"].toInt();
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
s.setValue("user_id", user_id_);
s.setValue("session_id", session_id_);
s.setValue("country_code", country_code_);
s.endGroup();
qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "session id" << session_id_ << "country code" << country_code_;
if (search_ctx) {
qLog(Debug) << "Tidal: Resuming search";
SendSearch(search_ctx);
}
emit LoginSuccess();
}
void TidalService::Logout() {
user_id_ = 0;
session_id_.clear();
country_code_.clear();
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
s.remove("user_id");
s.remove("session_id");
s.remove("country_code");
s.endGroup();
}
QNetworkReply *TidalService::CreateRequest(const QString &ressource_name, const QList<Param> &params) {
typedef QPair<QString, QString> Arg;
typedef QList<Arg> ArgList;
typedef QPair<QByteArray, QByteArray> EncodedArg;
typedef QList<EncodedArg> EncodedArgList;
ArgList args = ArgList() << params
<< Arg("sessionId", session_id_)
<< Arg("countryCode", country_code_);
QStringList query_items;
QUrlQuery url_query;
for (const Arg& arg : args) {
EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second));
query_items << QString(encoded_arg.first + "=" + encoded_arg.second);
url_query.addQueryItem(encoded_arg.first, encoded_arg.second);
}
QUrl url(kApiUrl + QString("/") + ressource_name);
url.setQuery(url_query);
QNetworkRequest req(url);
QNetworkReply *reply = network_->get(req);
//qLog(Debug) << "Tidal: Sending request" << url;
return reply;
}
QJsonObject TidalService::ExtractJsonObj(TidalSearchContext *search_ctx, QNetworkReply *reply) {
QByteArray data;
//int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() == QNetworkReply::NoError) {
data = reply->readAll();
}
else {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
Error(search_ctx, failure_reason);
}
else {
// See if there is Json data containing "userMessage" - then use that instead.
data = reply->readAll();
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
QString failure_reason;
if (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("userMessage")) {
failure_reason = json_obj["userMessage"].toString();
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
if (reply->error() == QNetworkReply::ContentAccessDenied || reply->error() == QNetworkReply::ContentOperationNotPermittedError || reply->error() == QNetworkReply::AuthenticationRequiredError) {
// Session is probably expired, attempt to login once
Logout();
if (search_ctx->login_attempts < 1 && !username_.isEmpty() && !password_.isEmpty()) {
qLog(Error) << "Tidal:" << failure_reason;
qLog(Error) << "Tidal:" << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
qLog(Error) << "Tidal:" << "Attempting to login.";
Login(search_ctx, username_, password_);
}
else {
Error(search_ctx, failure_reason);
}
}
else if (reply->error() == QNetworkReply::ContentNotFoundError) { // Ignore this error
qLog(Error) << "Tidal:" << failure_reason;
}
else { // Fail
Error(search_ctx, failure_reason);
}
}
return QJsonObject();
}
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
Error(search_ctx, "Reply from server missing Json data.");
return QJsonObject();
}
if (json_doc.isNull() || json_doc.isEmpty()) {
Error(search_ctx, "Received empty Json document.");
return QJsonObject();
}
if (!json_doc.isObject()) {
Error(search_ctx, "Json document is not an object.");
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
Error(search_ctx, "Received empty Json object.");
return QJsonObject();
}
//qLog(Debug) << json_obj;
return json_obj;
}
QJsonArray TidalService::ExtractItems(TidalSearchContext *search_ctx, QNetworkReply *reply) {
QJsonObject json_obj = ExtractJsonObj(search_ctx, reply);
if (json_obj.isEmpty()) return QJsonArray();
if (!json_obj.contains("items")) {
Error(search_ctx, "Json reply is missing items.");
return QJsonArray();
}
QJsonArray json_items = json_obj["items"].toArray();
if (json_items.isEmpty()) {
Error(search_ctx, "No match.");
return QJsonArray();
}
return json_items;
}
int TidalService::Search(const QString &text, TidalSettingsPage::SearchBy searchby) {
pending_search_id_ = next_pending_search_id_;
pending_search_ = text;
pending_searchby_ = searchby;
next_pending_search_id_++;
if (text.isEmpty()) {
search_delay_->stop();
return pending_search_id_;
}
search_delay_->start();
return pending_search_id_;
}
void TidalService::StartSearch() {
if (username_.isEmpty() || password_.isEmpty()) {
emit SearchError(pending_search_id_, "Missing username and/or password.");
next_pending_search_id_ = 1;
ShowConfig();
return;
}
TidalSearchContext *search_ctx = CreateSearch(pending_search_id_, pending_search_);
if (authenticated()) SendSearch(search_ctx);
else Login(search_ctx, username_, password_);
}
TidalSearchContext *TidalService::CreateSearch(const int search_id, const QString text) {
TidalSearchContext *search_ctx = new TidalSearchContext;
search_ctx->id = search_id;
search_ctx->text = text;
search_ctx->album_requests = 0;
search_ctx->song_requests = 0;
search_ctx->requests_album_.clear();
search_ctx->requests_song_.clear();
search_ctx->login_attempts = 0;
requests_search_.insert(search_id, search_ctx);
return search_ctx;
}
void TidalService::SendSearch(TidalSearchContext *search_ctx) {
QList<Param> parameters;
parameters << Param("query", search_ctx->text);
QString searchparam;
switch (pending_searchby_) {
case TidalSettingsPage::SearchBy_Songs:
searchparam = "search/tracks";
parameters << Param("limit", QString::number(kSearchTracksLimit));
break;
case TidalSettingsPage::SearchBy_Albums:
default:
searchparam = "search/albums";
parameters << Param("limit", QString::number(kSearchAlbumsLimit));
break;
}
QNetworkReply *reply = CreateRequest(searchparam, parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(SearchFinished(QNetworkReply*, int)), reply, search_ctx->id);
}
void TidalService::SearchFinished(QNetworkReply *reply, int id) {
reply->deleteLater();
if (!requests_search_.contains(id)) return;
TidalSearchContext *search_ctx = requests_search_.value(id);
QJsonArray json_items = ExtractItems(search_ctx, reply);
if (json_items.isEmpty()) {
CheckFinish(search_ctx);
return;
}
//qLog(Debug) << json_items;
QVector<QString> albums;
for (const QJsonValue &value : json_items) {
if (!value.isObject()) {
qLog(Error) << "Tidal: Invalid Json reply, item not a object.";
qLog(Debug) << value;
continue;
}
QJsonObject json_obj = value.toObject();
//qLog(Debug) << json_obj;
int album_id(0);
QString album("");
if (json_obj.contains("type")) {
// This was a albums search
if (!json_obj.contains("id") || !json_obj.contains("title")) {
qLog(Error) << "Tidal: Invalid Json reply, item is missing ID or title.";
qLog(Debug) << json_obj;
continue;
}
album_id = json_obj["id"].toInt();
album = json_obj["title"].toString();
}
else if (json_obj.contains("album")) {
// This was a tracks search
QJsonValue json_value_album = json_obj["album"];
if (!json_value_album.isObject()) {
qLog(Error) << "Tidal: Invalid Json reply, item album is not a object.";
qLog(Debug) << json_value_album;
continue;
}
QJsonObject json_album = json_value_album.toObject();
if (!json_album.contains("id") || !json_album.contains("title")) {
qLog(Error) << "Tidal: Invalid Json reply, item album is missing ID or title.";
qLog(Debug) << json_album;
continue;
}
album_id = json_album["id"].toInt();
album = json_album["title"].toString();
}
else {
qLog(Error) << "Tidal: Invalid Json reply, item missing type or album.";
qLog(Debug) << json_obj;
continue;
}
if (search_ctx->requests_album_.contains(album_id)) continue;
if (!json_obj.contains("artist") || !json_obj.contains("title") || !json_obj.contains("audioQuality")) {
qLog(Error) << "Tidal: Invalid Json reply, item missing artist, title or audioQuality.";
qLog(Debug) << json_obj;
continue;
}
QJsonValue json_value_artist = json_obj["artist"];
if (!json_value_artist.isObject()) {
qLog(Error) << "Tidal: Invalid Json reply, item artist is not a object.";
qLog(Debug) << json_value_artist;
continue;
}
QJsonObject json_artist = json_value_artist.toObject();
if (!json_artist.contains("name")) {
qLog(Error) << "Tidal: Invalid Json reply, item artist missing name.";
qLog(Debug) << json_artist;
continue;
}
QString artist = json_artist["name"].toString();
QString quality = json_obj["audioQuality"].toString();
//qLog(Debug) << "Tidal:" << artist << album << quality;
QString artist_album(QString("%1-%2").arg(artist).arg(album));
if (albums.contains(artist_album)) {
qLog(Debug) << "Tidal: Skipping duplicate album" << artist << album << quality;
continue;
}
albums.insert(0, artist_album);
search_ctx->requests_album_.insert(album_id, album_id);
GetAlbum(search_ctx, album_id);
search_ctx->album_requests++;
if (search_ctx->album_requests >= kSearchAlbumsLimit) break;
}
CheckFinish(search_ctx);
}
void TidalService::GetAlbum(TidalSearchContext *search_ctx, const int album_id) {
QList<Param> parameters;
parameters << Param("token", session_id_)
<< Param("soundQuality", quality_);
QNetworkReply *reply = CreateRequest(QString("albums/%1/tracks").arg(album_id), parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(GetAlbumFinished(QNetworkReply*, int, int)), reply, search_ctx->id, album_id);
}
void TidalService::GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id) {
reply->deleteLater();
if (!requests_search_.contains(search_id)) return;
TidalSearchContext *search_ctx = requests_search_.value(search_id);
if (!search_ctx->requests_album_.contains(album_id)) return;
search_ctx->album_requests--;
QJsonArray json_items = ExtractItems(search_ctx, reply);
if (json_items.isEmpty()) {
CheckFinish(search_ctx);
return;
}
bool compilation = false;
bool multidisc = false;
Song *first_song(nullptr);
QList<Song *> songs;
for (const QJsonValue &value : json_items) {
Song *song = ParseSong(search_ctx, album_id, value);
if (!song) continue;
songs << song;
if (song->disc() >= 2) multidisc = true;
if (song->is_compilation() || (first_song && song->artist() != first_song->artist())) compilation = true;
if (!first_song) first_song = song;
}
if (compilation || multidisc) {
for (Song *song : songs) {
if (compilation) song->set_compilation_detected(true);
if (multidisc) {
QString album_full(QString("%1 - (Disc %2)").arg(song->album()).arg(song->disc()));
song->set_album(album_full);
}
}
}
CheckFinish(search_ctx);
}
Song *TidalService::ParseSong(TidalSearchContext *search_ctx, const int album_id, const QJsonValue &value) {
Song song;
if (!value.isObject()) {
qLog(Error) << "Tidal: Invalid Json reply, track is not a object.";
qLog(Debug) << value;
return nullptr;
}
QJsonObject json_obj = value.toObject();
//qLog(Debug) << json_obj;
if (
!json_obj.contains("album") ||
!json_obj.contains("allowStreaming") ||
!json_obj.contains("artist") ||
!json_obj.contains("artists") ||
!json_obj.contains("audioQuality") ||
!json_obj.contains("duration") ||
!json_obj.contains("id") ||
!json_obj.contains("streamReady") ||
!json_obj.contains("title") ||
!json_obj.contains("trackNumber") ||
!json_obj.contains("url") ||
!json_obj.contains("volumeNumber")
) {
qLog(Error) << "Tidal: Invalid Json reply, track is missing one or more values.";
qLog(Debug) << json_obj;
return nullptr;
}
QJsonValue json_value_artist = json_obj["artist"];
QJsonValue json_value_album = json_obj["album"];
QJsonValue json_duration = json_obj["duration"];
QJsonArray json_artists = json_obj["artists"].toArray();
int id = json_obj["id"].toInt();
QString title = json_obj["title"].toString();
QString url = json_obj["url"].toString();
int track = json_obj["trackNumber"].toInt();
int disc = json_obj["volumeNumber"].toInt();
bool allow_streaming = json_obj["allowStreaming"].toBool();
bool stream_ready = json_obj["streamReady"].toBool();
if (!json_value_artist.isObject()) {
qLog(Error) << "Tidal: Invalid Json reply, track artist is not a object.";
qLog(Debug) << json_value_artist;
return nullptr;
}
QJsonObject json_artist = json_value_artist.toObject();
if (!json_artist.contains("name")) {
qLog(Error) << "Tidal: Invalid Json reply, track artist is missing name.";
qLog(Debug) << json_artist;
return nullptr;
}
QString artist = json_artist["name"].toString();
if (!json_value_album.isObject()) {
qLog(Error) << "Tidal: Invalid Json reply, track album is not a object.";
qLog(Debug) << json_value_album;
return nullptr;
}
QJsonObject json_album = json_value_album.toObject();
if (!json_album.contains("title") || !json_album.contains("cover")) {
qLog(Error) << "Tidal: Invalid Json reply, track album is missing title or cover.";
qLog(Debug) << json_album;
return nullptr;
}
QString album = json_album["title"].toString();
QString cover = json_album["cover"].toString();
if (!allow_streaming || !stream_ready) {
qLog(Error) << "Tidal: Skipping song" << artist << album << title << "because allowStreaming is false OR streamReady is false.";
qLog(Debug) << json_obj;
return nullptr;
}
//qLog(Debug) << "id" << id << "track" << track << "disc" << disc << "title" << title << "album" << album << "artist" << artist << cover << allow_streaming << url;
song.set_album_id(album_id);
song.set_artist(artist);
song.set_album(album);
song.set_title(title);
song.set_track(track);
song.set_disc(disc);
song.set_bitrate(0);
song.set_samplerate(0);
song.set_bitdepth(0);
QVariant q_duration = json_duration.toVariant();
if (q_duration.isValid()) {
quint64 duration = q_duration.toULongLong() * kNsecPerSec;
song.set_length_nanosec(duration);
}
// Check and see if there is more than 1 artist on the song.
//int i = 0;
//for (const QJsonValue &a : json_artists) {
//i++;
//qLog(Debug) << a << i;
//}
//if (i > 1) song.set_compilation_detected(true);
cover = cover.replace("-", "/");
QUrl cover_url (QString("%1/images/%2/750x750.jpg").arg(kResourcesUrl).arg(cover));
song.set_art_automatic(cover_url.toEncoded());
if (search_ctx->requests_song_.contains(id)) return search_ctx->requests_song_.value(id);
Song *song_new = new Song(song);
search_ctx->requests_song_.insert(id, song_new);
search_ctx->song_requests++;
GetStreamURL(search_ctx, album_id, id);
return song_new;
}
void TidalService::GetStreamURL(TidalSearchContext *search_ctx, const int album_id, const int song_id) {
QList<Param> parameters;
parameters << Param("token", session_id_)
<< Param("soundQuality", quality_);
QNetworkReply *reply = CreateRequest(QString("tracks/%1/streamUrl").arg(song_id), parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(GetStreamURLFinished(QNetworkReply*, int, int)), reply, search_ctx->id, song_id);
}
void TidalService::GetStreamURLFinished(QNetworkReply *reply, const int search_id, const int song_id) {
reply->deleteLater();
if (!requests_search_.contains(search_id)) return;
TidalSearchContext *search_ctx = requests_search_.value(search_id);
if (!search_ctx->requests_song_.contains(song_id)) {
CheckFinish(search_ctx);
return;
}
Song *song = search_ctx->requests_song_.value(song_id);
search_ctx->song_requests--;
QJsonObject json_obj = ExtractJsonObj(search_ctx, reply);
if (json_obj.isEmpty()) {
delete search_ctx->requests_song_.take(song_id);
CheckFinish(search_ctx);
return;
}
if (!json_obj.contains("url") || !json_obj.contains("codec")) {
qLog(Error) << "Tidal: Invalid Json reply, stream missing url or codec.";
qLog(Debug) << json_obj;
delete search_ctx->requests_song_.take(song_id);
CheckFinish(search_ctx);
return;
}
song->set_url(QUrl(json_obj["url"].toString()));
song->set_valid(true);
QString codec = json_obj["codec"].toString();
if (codec == "AAC") song->set_filetype(Song::Type_MP4);
else qLog(Debug) << "Tidal codec" << codec;
//qLog(Debug) << song->artist() << song->album() << song->title() << song->url() << song->filetype();
search_ctx->songs << *song;
delete search_ctx->requests_song_.take(song_id);
CheckFinish(search_ctx);
}
void TidalService::CheckFinish(TidalSearchContext *search_ctx) {
if (!search_ctx->login_sent && search_ctx->album_requests <= 0 && search_ctx->song_requests <= 0) {
if (search_ctx->songs.isEmpty()) emit SearchError(search_ctx->id, search_ctx->error);
else emit SearchResults(search_ctx->id, search_ctx->songs);
delete requests_search_.take(search_ctx->id);
}
}
void TidalService::Error(TidalSearchContext *search_ctx, QString error, QString debug) {
qLog(Error) << "Tidal:" << error;
if (!debug.isEmpty()) qLog(Debug) << debug;
if (search_ctx) {
search_ctx->error = error;
CheckFinish(search_ctx);
}
}

View File

@@ -0,0 +1,745 @@
/*
* 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 <QObject>
#include <QByteArray>
#include <QList>
#include <QVector>
#include <QPair>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QTimer>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include <QMenu>
#include <QSettings>
#include "core/application.h"
#include "core/closure.h"
#include "core/logging.h"
#include "core/mergedproxymodel.h"
#include "core/network.h"
#include "core/song.h"
#include "core/iconloader.h"
#include "core/taskmanager.h"
#include "core/timeconstants.h"
#include "core/utilities.h"
#include "internet/internetmodel.h"
#include "tidalservice.h"
#include "tidalsearch.h"
#include "settings/tidalsettingspage.h"
const char *TidalService::kServiceName = "Tidal";
const char *TidalService::kApiUrl = "https://listen.tidal.com/v1";
const char *TidalService::kAuthUrl = "https://listen.tidal.com/v1/login/username";
const char *TidalService::kResourcesUrl = "http://resources.tidal.com";
const char *TidalService::kApiToken = "P5Xbeo5LFvESeDy6";
const int TidalService::kSearchDelayMsec = 1000;
const int TidalService::kSearchAlbumsLimit = 1;
const int TidalService::kSearchTracksLimit = 1;
typedef QPair<QString, QString> Param;
TidalService::TidalService(Application *app, InternetModel *parent)
: InternetService(kServiceName, app, parent, parent),
network_(new NetworkAccessManager(this)),
search_delay_(new QTimer(this)),
pending_search_id_(0),
next_pending_search_id_(1),
search_requests_(0),
login_sent_(false) {
search_delay_->setInterval(kSearchDelayMsec);
search_delay_->setSingleShot(true);
connect(search_delay_, SIGNAL(timeout()), SLOT(StartSearch()));
ReloadSettings();
LoadSessionID();
}
TidalService::~TidalService() {}
void TidalService::ShowConfig() {
app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Tidal);
}
void TidalService::ReloadSettings() {
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
username_ = s.value("username").toString();
password_ = s.value("password").toString();
quality_ = s.value("quality").toString();
s.endGroup();
}
void TidalService::LoadSessionID() {
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
if (!s.contains("user_id") ||!s.contains("session_id") || !s.contains("country_code")) return;
session_id_ = s.value("session_id").toString();
user_id_ = s.value("user_id").toInt();
country_code_ = s.value("country_code").toString();
s.endGroup();
}
void TidalService::Login(const QString &username, const QString &password) {
Login(nullptr, username, password);
}
void TidalService::Login(TidalSearchContext *search_ctx, const QString &username, const QString &password) {
login_sent_ = true;
int id = 0;
if (search_ctx) {
search_ctx->login_sent = true;
search_ctx->login_attempts++;
id = search_ctx->id;
}
typedef QPair<QString, QString> Arg;
typedef QList<Arg> ArgList;
typedef QPair<QByteArray, QByteArray> EncodedArg;
typedef QList<EncodedArg> EncodedArgList;
ArgList args = ArgList() <<Arg("token", kApiToken) << Arg("username", username) << Arg("password", password) << Arg("clientVersion", "2.2.1--7");
QStringList query_items;
QUrlQuery url_query;
for (const Arg &arg : args) {
EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second));
query_items << QString(encoded_arg.first + "=" + encoded_arg.second);
url_query.addQueryItem(encoded_arg.first, encoded_arg.second);
}
QUrl url(kAuthUrl);
QNetworkRequest req(url);
req.setRawHeader("Origin", "http://listen.tidal.com");
QNetworkReply *reply = network_->post(req, url_query.toString(QUrl::FullyEncoded).toUtf8());
NewClosure(reply, SIGNAL(finished()), this, SLOT(HandleAuthReply(QNetworkReply*, int)), reply, id);
}
void TidalService::HandleAuthReply(QNetworkReply *reply, int id) {
reply->deleteLater();
login_sent_ = false;
TidalSearchContext *search_ctx(nullptr);
if (id != 0 && requests_search_.contains(id)) {
search_ctx = requests_search_.value(id);
search_ctx->login_sent = false;
}
//int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError) {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
else {
// See if there is Json data containing "userMessage" - then use that instead.
QByteArray data(reply->readAll());
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
QString failure_reason;
if (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("userMessage")) {
failure_reason = QString("Authentication failure: %1").arg(json_obj["userMessage"].toString());
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
}
QByteArray data(reply->readAll());
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
QString failure_reason("Authentication reply from server missing Json data.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
if (json_doc.isNull() || json_doc.isEmpty()) {
QString failure_reason("Authentication reply from server has empty Json document.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
if (!json_doc.isObject()) {
QString failure_reason("Authentication reply from server has Json document that is not an object.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
QString failure_reason("Authentication reply from server has empty Json object.");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
if (json_obj["userId"].isUndefined() || json_obj["sessionId"].isUndefined() || json_obj["countryCode"].isUndefined()) {
QString failure_reason = tr("Authentication reply from server missing userId, sessionId or countryCode");
if (search_ctx) Error(search_ctx, failure_reason);
emit LoginFailure(failure_reason);
return;
}
country_code_ = json_obj["countryCode"].toString();
session_id_ = json_obj["sessionId"].toString();
user_id_ = json_obj["userId"].toInt();
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
s.setValue("user_id", user_id_);
s.setValue("session_id", session_id_);
s.setValue("country_code", country_code_);
s.endGroup();
qLog(Debug) << "Tidal: Login successful" << "user id" << user_id_ << "session id" << session_id_ << "country code" << country_code_;
if (search_ctx) {
qLog(Debug) << "Tidal: Resuming search";
SendSearch(search_ctx);
}
emit LoginSuccess();
}
void TidalService::Logout() {
user_id_ = 0;
session_id_.clear();
country_code_.clear();
QSettings s;
s.beginGroup(TidalSettingsPage::kSettingsGroup);
s.remove("user_id");
s.remove("session_id");
s.remove("country_code");
s.endGroup();
}
QNetworkReply *TidalService::CreateRequest(const QString &ressource_name, const QList<Param> &params) {
typedef QPair<QString, QString> Arg;
typedef QList<Arg> ArgList;
typedef QPair<QByteArray, QByteArray> EncodedArg;
typedef QList<EncodedArg> EncodedArgList;
ArgList args = ArgList() << params
<< Arg("sessionId", session_id_)
<< Arg("countryCode", country_code_);
QStringList query_items;
QUrlQuery url_query;
for (const Arg& arg : args) {
EncodedArg encoded_arg(QUrl::toPercentEncoding(arg.first), QUrl::toPercentEncoding(arg.second));
query_items << QString(encoded_arg.first + "=" + encoded_arg.second);
url_query.addQueryItem(encoded_arg.first, encoded_arg.second);
}
QUrl url(kApiUrl + QString("/") + ressource_name);
url.setQuery(url_query);
QNetworkRequest req(url);
QNetworkReply *reply = network_->get(req);
//qLog(Debug) << "Tidal: Sending request" << url;
return reply;
}
QJsonObject TidalService::ExtractJsonObj(TidalSearchContext *search_ctx, QNetworkReply *reply) {
//int http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError) {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
Error(search_ctx, failure_reason);
}
else {
// See if there is Json data containing "userMessage" - then use that instead.
QByteArray data(reply->readAll());
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
QString failure_reason;
if (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("userMessage")) {
failure_reason = json_obj["userMessage"].toString();
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
}
else {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
if (reply->error() == QNetworkReply::ContentAccessDenied ||
reply->error() == QNetworkReply::ContentOperationNotPermittedError ||
reply->error() == QNetworkReply::ContentNotFoundError ||
reply->error() == QNetworkReply::AuthenticationRequiredError) {
Logout();
if (search_ctx->login_attempts < 1 && !username_.isEmpty() && !password_.isEmpty()) {
qLog(Error) << "Tidal:" << failure_reason;
qLog(Error) << "Tidal:" << QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
qLog(Error) << "Tidal:" << "Attempting to login.";
Login(search_ctx, username_, password_);
}
else {
Error(search_ctx, failure_reason);
}
}
else {
Error(search_ctx, failure_reason);
}
}
return QJsonObject();
}
QByteArray data(reply->readAll());
qLog(Debug) << data;
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &error);
if (error.error != QJsonParseError::NoError) {
Error(search_ctx, "Error while extracting Json document from results.");
return QJsonObject();
}
if (!json_doc.isObject()) {
Error(search_ctx, "Json document is not an object.");
return QJsonObject();
}
if (json_doc.isNull() || json_doc.isEmpty()) {
Error(search_ctx, "Received empty Json document.");
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
Error(search_ctx, "Received empty Json object.");
return QJsonObject();
}
//qLog(Debug) << json_obj;
return json_obj;
}
QJsonArray TidalService::ExtractItems(TidalSearchContext *search_ctx, QNetworkReply *reply) {
QJsonObject json_obj = ExtractJsonObj(search_ctx, reply);
if (json_obj.isEmpty()) return QJsonArray();
if (!json_obj.contains("items")) {
Error(search_ctx, "Json reply is missing items.");
return QJsonArray();
}
QJsonArray json_items = json_obj["items"].toArray();
if (json_items.isEmpty()) {
Error(search_ctx, "No match.");
return QJsonArray();
}
return json_items;
}
int TidalService::Search(const QString &text, TidalSettingsPage::SearchBy searchby) {
pending_search_id_ = next_pending_search_id_;
pending_search_ = text;
pending_searchby_ = searchby;
next_pending_search_id_++;
if (text.isEmpty()) {
search_delay_->stop();
return pending_search_id_;
}
search_delay_->start();
return pending_search_id_;
}
void TidalService::StartSearch() {
if (username_.isEmpty() || password_.isEmpty()) {
emit SearchError(pending_search_id_, "Missing username and/or password.");
next_pending_search_id_ = 1;
ShowConfig();
return;
}
TidalSearchContext *search_ctx = CreateSearch(pending_search_id_, pending_search_);
if (authenticated()) SendSearch(search_ctx);
else Login(search_ctx, username_, password_);
}
TidalSearchContext *TidalService::CreateSearch(const int search_id, const QString text) {
TidalSearchContext *search_ctx = new TidalSearchContext;
search_ctx->id = search_id;
search_ctx->text = text;
search_ctx->album_requests = 0;
search_ctx->song_requests = 0;
search_ctx->requests_album_.clear();
search_ctx->requests_song_.clear();
search_ctx->login_attempts = 0;
requests_search_.insert(search_id, search_ctx);
return search_ctx;
}
void TidalService::SendSearch(TidalSearchContext *search_ctx) {
QList<Param> parameters;
parameters << Param("query", search_ctx->text);
QString searchparam;
switch (pending_searchby_) {
case TidalSettingsPage::SearchBy_Songs:
searchparam = "search/tracks";
parameters << Param("limit", QString::number(kSearchTracksLimit));
break;
case TidalSettingsPage::SearchBy_Albums:
default:
searchparam = "search/albums";
parameters << Param("limit", QString::number(kSearchAlbumsLimit));
break;
}
QNetworkReply *reply = CreateRequest(searchparam, parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(SearchFinished(QNetworkReply*, int)), reply, search_ctx->id);
}
void TidalService::SearchFinished(QNetworkReply *reply, int id) {
reply->deleteLater();
if (!requests_search_.contains(id)) return;
TidalSearchContext *search_ctx = requests_search_.value(id);
QJsonArray json_items = ExtractItems(search_ctx, reply);
if (json_items.isEmpty()) {
CheckFinish(search_ctx);
return;
}
//qLog(Debug) << json_items;
QVector<QString> albums;
for (const QJsonValue &value : json_items) {
int album_id(0);
QString album("");
if (!value["type"].isUndefined()) {
if (value["id"].isUndefined() || value["title"].isUndefined()) {
qLog(Error) << "Tidal: Invalid Json reply, missing ID or title.";
qLog(Debug) << value;
continue;
}
album_id = value["id"].toInt();
album = value["title"].toString();
}
else if (!value["album"].isUndefined()) {
QJsonValue json_album = value["album"];
if (json_album["id"].isUndefined() || json_album["title"].isUndefined()) {
qLog(Error) << "Tidal: Invalid Json reply, missing ID or title.";
qLog(Debug) << value;
continue;
}
album_id = json_album["id"].toInt();
album = json_album["title"].toString();
}
else {
qLog(Error) << "Tidal: Invalid Json reply, missing type or album.";
qLog(Debug) << value;
continue;
}
if (search_ctx->requests_album_.contains(album_id)) continue;
if (value["artist"].isUndefined() || value["title"].isUndefined()) {
qLog(Error) << "Tidal: Invalid Json reply, missing artist or title.";
qLog(Debug) << value;
continue;
}
QJsonValue json_artist = value["artist"];
QString artist(json_artist["name"].toString());
QString quality(value["audioQuality"].toString());
//qLog(Debug) << "Tidal:" << artist << album << quality;
QString artist_album(QString("%1-%2").arg(artist).arg(album));
if (albums.contains(artist_album)) {
qLog(Debug) << "Tidal: Skipping duplicate album" << artist << album << quality;
continue;
}
albums.insert(0, artist_album);
search_ctx->requests_album_.insert(album_id, album_id);
GetAlbum(search_ctx, album_id);
search_ctx->album_requests++;
if (search_ctx->album_requests >= kSearchAlbumsLimit) break;
}
CheckFinish(search_ctx);
}
void TidalService::GetAlbum(TidalSearchContext *search_ctx, const int album_id) {
QList<Param> parameters;
parameters << Param("token", session_id_)
<< Param("soundQuality", quality_);
QNetworkReply *reply = CreateRequest(QString("albums/%1/tracks").arg(album_id), parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(GetAlbumFinished(QNetworkReply*, int, int)), reply, search_ctx->id, album_id);
}
void TidalService::GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id) {
reply->deleteLater();
if (!requests_search_.contains(search_id)) return;
TidalSearchContext *search_ctx = requests_search_.value(search_id);
if (!search_ctx->requests_album_.contains(album_id)) return;
search_ctx->album_requests--;
QJsonArray json_items = ExtractItems(search_ctx, reply);
if (json_items.isEmpty()) {
CheckFinish(search_ctx);
return;
}
bool compilation = false;
bool multidisc = false;
Song *first_song(nullptr);
QList<Song *> songs;
for (const QJsonValue &value : json_items) {
Song *song = ParseSong(search_ctx, album_id, value);
if (!song) continue;
songs << song;
if (song->disc() >= 2) multidisc = true;
if (song->is_compilation() || (first_song && song->artist() != first_song->artist())) compilation = true;
if (!first_song) first_song = song;
}
if (compilation || multidisc) {
for (Song *song : songs) {
if (compilation) song->set_compilation_detected(true);
if (multidisc) {
QString album_full(QString("%1 - (Disc %2)").arg(song->album()).arg(song->disc()));
song->set_album(album_full);
}
}
}
CheckFinish(search_ctx);
}
Song *TidalService::ParseSong(TidalSearchContext *search_ctx, const int album_id, const QJsonValue &value) {
Song song;
bool allow_streaming = value["allowStreaming"].toBool();
bool stream_ready = value["streamReady"].toBool();
if (!allow_streaming || !stream_ready) {
return nullptr;
}
int id = value["id"].toInt();
QJsonValue json_artist = value["artist"];
QJsonArray json_artists = value["artists"].toArray();
QJsonValue json_album = value["album"];
QString title = value["title"].toString();
QString artist = json_artist["name"].toString();
QString album = json_album["title"].toString();
QString cover = json_album["cover"].toString();
QString url = value["url"].toString();
int track = value["trackNumber"].toInt();
int disc = value["volumeNumber"].toInt();
//qLog(Debug) << "id" << id << "track" << track << "disc" << disc << "title" << title << "album" << album << "artist" << artist << cover << allow_streaming << url;
song.set_album_id(album_id);
song.set_artist(artist);
song.set_album(album);
song.set_title(title);
song.set_track(track);
song.set_disc(disc);
song.set_bitrate(0);
song.set_samplerate(0);
song.set_bitdepth(0);
QVariant q_duration = value["duration"];
if (q_duration.isValid()) {
quint64 duration = q_duration.toULongLong() * kNsecPerSec;
song.set_length_nanosec(duration);
}
// Check and see if there is more than 1 artist on the song.
//int i = 0;
//for (const QJsonValue &artist : json_artists) {
//i++;
//qLog(Debug) << artist << i;
//}
//if (i > 1) song.set_compilation_detected(true);
cover = cover.replace("-", "/");
QUrl cover_url (QString("%1/images/%2/750x750.jpg").arg(kResourcesUrl).arg(cover));
song.set_art_automatic(cover_url.toEncoded());
if (search_ctx->requests_song_.contains(id)) return search_ctx->requests_song_.value(id);
Song *song_new = new Song(song);
search_ctx->requests_song_.insert(id, song_new);
search_ctx->song_requests++;
GetStreamURL(search_ctx, album_id, id);
return song_new;
}
void TidalService::GetStreamURL(TidalSearchContext *search_ctx, const int album_id, const int song_id) {
QList<Param> parameters;
parameters << Param("token", session_id_)
<< Param("soundQuality", quality_);
QNetworkReply *reply = CreateRequest(QString("tracks/%1/streamUrl").arg(song_id), parameters);
NewClosure(reply, SIGNAL(finished()), this, SLOT(GetStreamURLFinished(QNetworkReply*, int, int)), reply, search_ctx->id, song_id);
}
void TidalService::GetStreamURLFinished(QNetworkReply *reply, const int search_id, const int song_id) {
reply->deleteLater();
if (!requests_search_.contains(search_id)) return;
TidalSearchContext *search_ctx = requests_search_.value(search_id);
if (!search_ctx->requests_song_.contains(song_id)) {
CheckFinish(search_ctx);
return;
}
Song *song = search_ctx->requests_song_.value(song_id);
search_ctx->song_requests--;
QJsonObject json_obj = ExtractJsonObj(search_ctx, reply);
if (json_obj.isEmpty()) {
delete search_ctx->requests_song_.take(song_id);
CheckFinish(search_ctx);
return;
}
if (json_obj["url"].isUndefined() || json_obj["codec"].isUndefined()) {
delete search_ctx->requests_song_.take(song_id);
CheckFinish(search_ctx);
return;
}
song->set_url(QUrl(json_obj["url"].toString()));
song->set_valid(true);
QString codec = json_obj["codec"].toString();
if (codec == "AAC") song->set_filetype(Song::Type_MP4);
else qLog(Debug) << "Tidal codec" << codec;
//qLog(Debug) << song->title() << song->artist() << song->album() << song->url() << song->filetype();
search_ctx->songs << *song;
delete search_ctx->requests_song_.take(song_id);
CheckFinish(search_ctx);
}
void TidalService::CheckFinish(TidalSearchContext *search_ctx) {
if (!search_ctx->login_sent && search_ctx->album_requests <= 0 && search_ctx->song_requests <= 0) {
if (search_ctx->songs.isEmpty()) emit SearchError(search_ctx->id, search_ctx->error);
else emit SearchResults(search_ctx->id, search_ctx->songs);
delete requests_search_.take(search_ctx->id);
}
}
void TidalService::Error(TidalSearchContext *search_ctx, QString error, QString debug) {
qLog(Error) << "Tidal:" << error;
if (!debug.isEmpty()) qLog(Debug) << debug;
if (search_ctx) {
search_ctx->error = error;
CheckFinish(search_ctx);
}
}

134
src/tidal/tidalservice.h Normal file
View File

@@ -0,0 +1,134 @@
/*
* 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 TIDALSERVICE_H
#define TIDALSERVICE_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QHash>
#include <QString>
#include <QNetworkReply>
#include <QTimer>
#include <QDateTime>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include "core/song.h"
#include "internet/internetmodel.h"
#include "internet/internetservice.h"
#include "settings/tidalsettingspage.h"
class NetworkAccessManager;
struct TidalSearchContext {
int id;
QString text;
QHash<int, int> requests_album_;
QHash<int, Song *> requests_song_;
int album_requests;
int song_requests;
SongList songs;
QString error;
bool login_sent;
int login_attempts;
};
Q_DECLARE_METATYPE(TidalSearchContext);
class TidalService : public InternetService {
Q_OBJECT
public:
TidalService(Application *app, InternetModel *parent);
~TidalService();
static const char *kServiceName;
void ReloadSettings();
void Login(const QString &username, const QString &password);
void Logout();
int Search(const QString &query, TidalSettingsPage::SearchBy searchby);
const bool login_sent() { return login_sent_; }
const bool authenticated() { return (!session_id_.isEmpty() && !country_code_.isEmpty()); }
signals:
void LoginSuccess();
void LoginFailure(QString failure_reason);
void SearchResults(int id, SongList songs);
void SearchError(int id, QString message);
public slots:
void ShowConfig();
private slots:
void HandleAuthReply(QNetworkReply *reply, int id);
void StartSearch();
void SearchFinished(QNetworkReply *reply, int id);
void GetAlbumFinished(QNetworkReply *reply, int search_id, int album_id);
void GetStreamURLFinished(QNetworkReply *reply, const int search_id, const int song_id);
private:
void Login(TidalSearchContext *search_ctx, const QString &username, const QString &password);
void LoadSessionID();
QNetworkReply *CreateRequest(const QString &ressource_name, const QList<QPair<QString, QString>> &params);
QJsonObject ExtractJsonObj(TidalSearchContext *search_ctx, QNetworkReply *reply);
QJsonArray ExtractItems(TidalSearchContext *search_ctx, QNetworkReply *reply);
TidalSearchContext *CreateSearch(const int search_id, const QString text);
void SendSearch(TidalSearchContext *search_ctx);
void GetAlbum(TidalSearchContext *search_ctx, const int album_id);
Song *ParseSong(TidalSearchContext *search_ctx, const int album_id, const QJsonValue &value);
Song ExtractSong(TidalSearchContext *search_ctx, const QJsonValue &value);
void GetStreamURL(TidalSearchContext *search_ctx, const int album_id, const int song_id);
void CheckFinish(TidalSearchContext *search_ctx);
void Error(TidalSearchContext *search_ctx, QString error, QString debug = "");
static const char *kApiUrl;
static const char *kAuthUrl;
static const char *kResourcesUrl;
static const char *kApiToken;
NetworkAccessManager *network_;
QTimer *search_delay_;
int pending_search_id_;
int next_pending_search_id_;
int search_requests_;
bool login_sent_;
static const int kSearchAlbumsLimit;
static const int kSearchTracksLimit;
static const int kSearchDelayMsec;
QString username_;
QString password_;
QString quality_;
QString session_id_;
quint64 user_id_;
QString country_code_;
QString pending_search_;
TidalSettingsPage::SearchBy pending_searchby_;
QHash<int, TidalSearchContext*> requests_search_;
};
#endif // TIDALSERVICE_H