Add Subsonic support (#180)

This commit is contained in:
Jonas Kvinge
2019-06-17 23:54:24 +02:00
committed by GitHub
parent a9da8811fc
commit 7b54cef23b
44 changed files with 2656 additions and 43 deletions

View File

@@ -345,6 +345,7 @@ optional_component(TRANSLATIONS ON "Translations"
)
optional_component(TIDAL ON "Tidal support")
optional_component(SUBSONIC ON "Subsonic support")
optional_component(MOODBAR ON "Moodbar"
DEPENDS "fftw3" FFTW3_FOUND

View File

@@ -25,7 +25,7 @@ Strawberry is a music player and music collection organizer. It is a fork of Cle
* Audio analyzer
* Audio equalizer
* Transfer music to iPod, iPhone, MTP or mass-storage USB player
* Streaming support for Tidal
* Streaming support for Tidal and Subsonic
* Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
It has so far been tested to work on Linux, OpenBSD, macOS and Windows.

View File

@@ -6,6 +6,7 @@
<file>schema/schema-3.sql</file>
<file>schema/schema-4.sql</file>
<file>schema/schema-5.sql</file>
<file>schema/schema-6.sql</file>
<file>schema/device-schema.sql</file>
<file>style/strawberry.css</file>
<file>html/playing-tooltip-plain.html</file>

View File

@@ -88,6 +88,7 @@
<file>icons/128x128/scrobble-disabled.png</file>
<file>icons/128x128/moodbar.png</file>
<file>icons/128x128/love.png</file>
<file>icons/128x128/subsonic.png</file>
<file>icons/64x64/albums.png</file>
<file>icons/64x64/alsa.png</file>
<file>icons/64x64/application-exit.png</file>
@@ -176,6 +177,7 @@
<file>icons/64x64/scrobble-disabled.png</file>
<file>icons/64x64/moodbar.png</file>
<file>icons/64x64/love.png</file>
<file>icons/64x64/subsonic.png</file>
<file>icons/48x48/albums.png</file>
<file>icons/48x48/alsa.png</file>
<file>icons/48x48/application-exit.png</file>
@@ -267,6 +269,7 @@
<file>icons/48x48/scrobble-disabled.png</file>
<file>icons/48x48/moodbar.png</file>
<file>icons/48x48/love.png</file>
<file>icons/48x48/subsonic.png</file>
<file>icons/32x32/albums.png</file>
<file>icons/32x32/alsa.png</file>
<file>icons/32x32/application-exit.png</file>
@@ -359,6 +362,7 @@
<file>icons/32x32/scrobble-disabled.png</file>
<file>icons/32x32/moodbar.png</file>
<file>icons/32x32/love.png</file>
<file>icons/32x32/subsonic.png</file>
<file>icons/22x22/albums.png</file>
<file>icons/22x22/alsa.png</file>
<file>icons/22x22/application-exit.png</file>
@@ -451,5 +455,6 @@
<file>icons/22x22/scrobble-disabled.png</file>
<file>icons/22x22/moodbar.png</file>
<file>icons/22x22/love.png</file>
<file>icons/22x22/subsonic.png</file>
</qresource>
</RCC>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

73
data/schema/schema-6.sql Normal file
View File

@@ -0,0 +1,73 @@
CREATE TABLE IF NOT EXISTS subsonic_songs (
title TEXT NOT NULL,
album TEXT NOT NULL,
artist TEXT NOT NULL,
albumartist TEXT NOT NULL,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT 0,
genre TEXT NOT NULL,
compilation INTEGER NOT NULL DEFAULT -1,
composer TEXT NOT NULL,
performer TEXT NOT NULL,
grouping TEXT NOT NULL,
comment TEXT NOT NULL,
lyrics TEXT NOT NULL,
artist_id INTEGER NOT NULL DEFAULT -1,
album_id INTEGER NOT NULL DEFAULT -1,
song_id INTEGER NOT NULL DEFAULT -1,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT 0,
samplerate INTEGER NOT NULL DEFAULT 0,
bitdepth INTEGER NOT NULL DEFAULT 0,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL,
filename TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0,
ctime INTEGER NOT NULL DEFAULT 0,
unavailable INTEGER DEFAULT 0,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT 0,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT
);
CREATE VIRTUAL TABLE IF NOT EXISTS subsonic_songs_fts USING fts3(
ftstitle,
ftsalbum,
ftsartist,
ftsalbumartist,
ftscomposer,
ftsperformer,
ftsgrouping,
ftsgenre,
ftscomment,
tokenize=unicode
);
UPDATE schema_version SET version=6;

View File

@@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS schema_version (
DELETE FROM schema_version;
INSERT INTO schema_version (version) VALUES (5);
INSERT INTO schema_version (version) VALUES (6);
CREATE TABLE IF NOT EXISTS directories (
path TEXT NOT NULL,
@@ -245,6 +245,63 @@ CREATE TABLE IF NOT EXISTS tidal_songs (
);
CREATE TABLE IF NOT EXISTS subsonic_songs (
title TEXT NOT NULL,
album TEXT NOT NULL,
artist TEXT NOT NULL,
albumartist TEXT NOT NULL,
track INTEGER NOT NULL DEFAULT -1,
disc INTEGER NOT NULL DEFAULT -1,
year INTEGER NOT NULL DEFAULT -1,
originalyear INTEGER NOT NULL DEFAULT 0,
genre TEXT NOT NULL,
compilation INTEGER NOT NULL DEFAULT -1,
composer TEXT NOT NULL,
performer TEXT NOT NULL,
grouping TEXT NOT NULL,
comment TEXT NOT NULL,
lyrics TEXT NOT NULL,
artist_id INTEGER NOT NULL DEFAULT -1,
album_id INTEGER NOT NULL DEFAULT -1,
song_id INTEGER NOT NULL DEFAULT -1,
beginning INTEGER NOT NULL DEFAULT 0,
length INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT 0,
samplerate INTEGER NOT NULL DEFAULT 0,
bitdepth INTEGER NOT NULL DEFAULT 0,
source INTEGER NOT NULL DEFAULT 0,
directory_id INTEGER NOT NULL,
filename TEXT NOT NULL,
filetype INTEGER NOT NULL DEFAULT 0,
filesize INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0,
ctime INTEGER NOT NULL DEFAULT 0,
unavailable INTEGER DEFAULT 0,
playcount INTEGER NOT NULL DEFAULT 0,
skipcount INTEGER NOT NULL DEFAULT 0,
lastplayed INTEGER NOT NULL DEFAULT 0,
compilation_detected INTEGER DEFAULT 0,
compilation_on INTEGER NOT NULL DEFAULT 0,
compilation_off INTEGER NOT NULL DEFAULT 0,
compilation_effective INTEGER NOT NULL DEFAULT 0,
art_automatic TEXT,
art_manual TEXT,
effective_albumartist TEXT,
effective_originalyear INTEGER NOT NULL DEFAULT 0,
cue_path TEXT
);
CREATE TABLE IF NOT EXISTS playlists (
name TEXT NOT NULL,

2
debian/control vendored
View File

@@ -66,7 +66,7 @@ Description: Audio player and music collection organizer
- Audio analyzer
- Audio equalizer
- Transfer music to iPod, iPhone, MTP or mass-storage USB player
- Streaming support for Tidal
- Streaming support for Tidal and Subsonic
- Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
.
It is a fork of Clementine. The name is inspired by the band Strawbs.

View File

@@ -106,7 +106,7 @@ Features:
- Audio analyzer
- Audio equalizer
- Transfer music to iPod, iPhone, MTP or mass-storage USB player
- Streaming support for Tidal
- Streaming support for Tidal and Subsonic
- Scrobbler with support for Last.fm, Libre.fm and ListenBrainz
%prep

View File

@@ -34,7 +34,7 @@
<li>Audio analyzer</li>
<li>Audio equalizer</li>
<li>Transfer music to iPod, iPhone, MTP or mass-storage USB player</li>
<li>Streaming support for Tidal</li>
<li>Streaming support for Tidal and Subsonic</li>
<li>Scrobbler with support for Last.fm, Libre.fm and ListenBrainz</li>
</ul>
</description>

View File

@@ -264,6 +264,7 @@ set(SOURCES
internet/internetsearchsortmodel.cpp
internet/internetsearchitemdelegate.cpp
internet/localredirectserver.cpp
internet/internetsongsview.cpp
internet/internettabsview.cpp
internet/internetcollectionview.cpp
internet/internetcollectionviewcontainer.cpp
@@ -443,6 +444,7 @@ set(HEADERS
internet/internetsearchview.h
internet/internetsearchmodel.h
internet/localredirectserver.h
internet/internetsongsview.h
internet/internettabsview.h
internet/internetcollectionview.h
internet/internetcollectionviewcontainer.h
@@ -909,6 +911,23 @@ optional_source(HAVE_TIDAL
settings/tidalsettingspage.ui
)
optional_source(HAVE_SUBSONIC
SOURCES
subsonic/subsonicservice.cpp
subsonic/subsonicurlhandler.cpp
subsonic/subsonicbaserequest.cpp
subsonic/subsonicrequest.cpp
settings/subsonicsettingspage.cpp
HEADERS
subsonic/subsonicservice.h
subsonic/subsonicurlhandler.h
subsonic/subsonicbaserequest.h
subsonic/subsonicrequest.h
settings/subsonicsettingspage.h
UI
settings/subsonicsettingspage.ui
)
# Moodbar
optional_source(HAVE_MOODBAR
SOURCES

View File

@@ -50,6 +50,7 @@
#cmakedefine XINE_ANALYZER
#cmakedefine HAVE_TIDAL
#cmakedefine HAVE_SUBSONIC
#cmakedefine HAVE_MOODBAR

View File

@@ -71,6 +71,10 @@
# include "covermanager/tidalcoverprovider.h"
#endif
#ifdef HAVE_SUBSONIC
# include "subsonic/subsonicservice.h"
#endif
#ifdef HAVE_MOODBAR
# include "moodbar/moodbarcontroller.h"
# include "moodbar/moodbarloader.h"
@@ -135,6 +139,9 @@ class ApplicationImpl {
InternetServices *internet_services = new InternetServices(app);
#ifdef HAVE_TIDAL
internet_services->AddService(new TidalService(app, internet_services));
#endif
#ifdef HAVE_SUBSONIC
internet_services->AddService(new SubsonicService(app, internet_services));
#endif
return internet_services;
}),

View File

@@ -52,7 +52,7 @@
#include "scopedtransaction.h"
const char *Database::kDatabaseFilename = "strawberry.db";
const int Database::kSchemaVersion = 5;
const int Database::kSchemaVersion = 6;
const char *Database::kMagicAllSongsTables = "%allsongstables";
int Database::sNextConnectionId = 1;

View File

@@ -138,9 +138,14 @@
# include "tidal/tidalservice.h"
# include "settings/tidalsettingspage.h"
#endif
#ifdef HAVE_SUBSONIC
# include "subsonic/subsonicservice.h"
# include "settings/subsonicsettingspage.h"
#endif
#include "internet/internetservices.h"
#include "internet/internetservice.h"
#include "internet/internetsongsview.h"
#include "internet/internettabsview.h"
#include "scrobbler/audioscrobbler.h"
@@ -210,6 +215,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co
}),
#ifdef HAVE_TIDAL
tidal_view_(new InternetTabsView(app_, app->internet_services()->ServiceBySource(Song::Source_Tidal), app_->tidal_search(), TidalSettingsPage::kSettingsGroup, SettingsDialog::Page_Tidal, this)),
#endif
#ifdef HAVE_SUBSONIC
subsonic_view_(new InternetSongsView(app_, app->internet_services()->ServiceBySource(Song::Source_Subsonic), SubsonicSettingsPage::kSettingsGroup, SettingsDialog::Page_Subsonic, this)),
#endif
playlist_menu_(new QMenu(this)),
playlist_add_to_another_(nullptr),
@@ -265,6 +273,9 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co
#ifdef HAVE_TIDAL
ui_->tabs->AddTab(tidal_view_, "tidal", IconLoader::Load("tidal"), tr("Tidal"));
#endif
#ifdef HAVE_SUBSONIC
ui_->tabs->AddTab(subsonic_view_, "subsonic", IconLoader::Load("subsonic"), tr("Subsonic"));
#endif
// Add the playing widget to the fancy tab widget
ui_->tabs->addBottomWidget(ui_->widget_playing);
@@ -558,6 +569,10 @@ MainWindow::MainWindow(Application *app, SystemTrayIcon *tray_icon, OSD *osd, co
#endif
#ifdef HAVE_SUBSONIC
connect(subsonic_view_->view(), SIGNAL(AddToPlaylistSignal(QMimeData*)), SLOT(AddToPlaylist(QMimeData*)));
#endif
// Playlist menu
playlist_play_pause_ = playlist_menu_->addAction(tr("Play"), this, SLOT(PlaylistPlay()));
playlist_menu_->addAction(ui_->action_stop);
@@ -874,6 +889,16 @@ void MainWindow::ReloadSettings() {
ui_->tabs->DisableTab(tidal_view_);
#endif
#ifdef HAVE_SUBSONIC
settings.beginGroup(SubsonicSettingsPage::kSettingsGroup);
bool enable_subsonic = settings.value("enabled", false).toBool();
settings.endGroup();
if (enable_subsonic)
ui_->tabs->EnableTab(subsonic_view_);
else
ui_->tabs->DisableTab(subsonic_view_);
#endif
}
void MainWindow::ReloadAllSettings() {
@@ -892,6 +917,9 @@ void MainWindow::ReloadAllSettings() {
#ifdef HAVE_TIDAL
tidal_view_->ReloadSettings();
#endif
#ifdef HAVE_SUBSONIC
subsonic_view_->ReloadSettings();
#endif
}

View File

@@ -91,6 +91,7 @@ class TranscodeDialog;
#endif
class Ui_MainWindow;
class Windows7ThumbBar;
class InternetSongsView;
class InternetTabsView;
class MainWindow : public QMainWindow, public PlatformInterface {
@@ -313,6 +314,7 @@ signals:
#endif
InternetTabsView *tidal_view_;
InternetSongsView *subsonic_view_;
QAction *collection_show_all_;
QAction *collection_show_duplicates_;

View File

@@ -333,7 +333,7 @@ bool Song::has_cue() const { return !d->cue_path_.isEmpty(); }
bool Song::is_collection_song() const { return !is_cdda() && !is_stream() && id() != -1; }
bool Song::is_metadata_good() const { return !d->title_.isEmpty() && !d->album_.isEmpty() && !d->artist_.isEmpty() && !d->url_.isEmpty() && d->end_ > 0; }
bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal; }
bool Song::is_stream() const { return d->source_ == Source_Stream || d->source_ == Source_Tidal || d->source_ == Source_Subsonic; }
bool Song::is_cdda() const { return d->source_ == Source_CDDA; }
const QString &Song::error() const { return d->error_; }
@@ -410,6 +410,7 @@ Song::Source Song::SourceFromURL(const QUrl &url) {
if (url.scheme() == "file") return Source_LocalFile;
else if (url.scheme() == "cdda") return Source_CDDA;
else if (url.scheme() == "tidal") return Source_Tidal;
else if (url.scheme() == "subsonic") return Source_Subsonic;
else if (url.scheme() == "http" || url.scheme() == "https" || url.scheme() == "rtsp") return Source_Stream;
else return Source_Unknown;
@@ -424,8 +425,10 @@ QString Song::TextForSource(Source source) {
case Song::Source_Device: return QObject::tr("Device");
case Song::Source_Stream: return QObject::tr("Stream");
case Song::Source_Tidal: return QObject::tr("Tidal");
default: return QObject::tr("Unknown");
case Song::Source_Subsonic: return QObject::tr("subsonic");
case Song::Source_Unknown: return QObject::tr("Unknown");
}
return QObject::tr("Unknown");
}
@@ -438,8 +441,10 @@ QIcon Song::IconForSource(Source source) {
case Song::Source_Device: return IconLoader::Load("device");
case Song::Source_Stream: return IconLoader::Load("applications-internet");
case Song::Source_Tidal: return IconLoader::Load("tidal");
default: return IconLoader::Load("edit-delete");
case Song::Source_Subsonic: return IconLoader::Load("subsonic");
case Song::Source_Unknown: return IconLoader::Load("edit-delete");
}
return IconLoader::Load("edit-delete");
}

View File

@@ -74,6 +74,7 @@ class Song {
Source_Device = 4,
Source_Stream = 5,
Source_Tidal = 6,
Source_Subsonic = 7,
};
// Don't change these values - they're stored in the database, and defined in the tag reader protobuf.

View File

@@ -54,7 +54,7 @@ class InternetCollectionView : public AutoExpandingTreeView {
public:
InternetCollectionView(QWidget *parent = nullptr);
~InternetCollectionView();
void Init(Application *app, CollectionBackend *backend, CollectionModel *model);
// Returns Songs currently selected in the collection view.

View File

@@ -54,5 +54,4 @@ InternetCollectionViewContainer::InternetCollectionViewContainer(QWidget *parent
InternetCollectionViewContainer::~InternetCollectionViewContainer() { delete ui_; }
void InternetCollectionViewContainer::contextMenuEvent(QContextMenuEvent *e) {
}
void InternetCollectionViewContainer::contextMenuEvent(QContextMenuEvent *e) {}

View File

@@ -29,13 +29,6 @@
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QPushButton" name="refresh">
<property name="text">
<string>Refresh catalogue</string>
</property>
</widget>
</item>
<item>
<widget class="QStackedWidget" name="stacked">
<widget class="QWidget" name="help_page">
@@ -135,6 +128,13 @@
<item>
<widget class="CollectionFilterWidget" name="filter" native="true"/>
</item>
<item>
<widget class="QPushButton" name="refresh">
<property name="text">
<string>Refresh catalogue</string>
</property>
</widget>
</item>
<item>
<widget class="InternetCollectionView" name="view"/>
</item>

View File

@@ -24,11 +24,11 @@
#include <QString>
#include <QUrl>
#include <QIcon>
#include <QSortFilterProxyModel>
#include "core/song.h"
#include "internetsearch.h"
class QSortFilterProxyModel;
class Application;
class CollectionBackend;
class CollectionModel;
@@ -38,6 +38,7 @@ class InternetService : public QObject {
public:
InternetService(Song::Source source, const QString &name, const QString &url_scheme, Application *app, QObject *parent = nullptr);
virtual ~InternetService() {}
virtual Song::Source source() const { return source_; }
@@ -47,40 +48,45 @@ class InternetService : public QObject {
virtual void InitialLoadSettings() {}
virtual void ReloadSettings() {}
virtual QIcon Icon() { return Song::IconForSource(source_); }
virtual const bool oauth() = 0;
virtual const bool authenticated() = 0;
virtual int Search(const QString &query, InternetSearch::SearchType type) = 0;
virtual void CancelSearch() = 0;
virtual const bool oauth() { return false; }
virtual const bool authenticated() { return false; }
virtual int Search(const QString &query, InternetSearch::SearchType type) { return 0; }
virtual void CancelSearch() {}
virtual CollectionBackend *artists_collection_backend() = 0;
virtual CollectionBackend *albums_collection_backend() = 0;
virtual CollectionBackend *songs_collection_backend() = 0;
virtual CollectionBackend *artists_collection_backend() { return nullptr; }
virtual CollectionBackend *albums_collection_backend() { return nullptr; }
virtual CollectionBackend *songs_collection_backend() { return nullptr; }
virtual CollectionModel *artists_collection_model() = 0;
virtual CollectionModel *albums_collection_model() = 0;
virtual CollectionModel *songs_collection_model() = 0;
virtual CollectionModel *artists_collection_model() { return nullptr; }
virtual CollectionModel *albums_collection_model() { return nullptr; }
virtual CollectionModel *songs_collection_model() { return nullptr; }
virtual QSortFilterProxyModel *artists_collection_sort_model() = 0;
virtual QSortFilterProxyModel *albums_collection_sort_model() = 0;
virtual QSortFilterProxyModel *songs_collection_sort_model() = 0;
virtual QSortFilterProxyModel *artists_collection_sort_model() { return nullptr; }
virtual QSortFilterProxyModel *albums_collection_sort_model() { return nullptr; }
virtual QSortFilterProxyModel *songs_collection_sort_model() { return nullptr; }
public slots:
virtual void ShowConfig() {}
virtual void GetArtists() = 0;
virtual void GetAlbums() = 0;
virtual void GetSongs() = 0;
virtual void ResetArtistsRequest() = 0;
virtual void ResetAlbumsRequest() = 0;
virtual void ResetSongsRequest() = 0;
virtual void GetArtists() {}
virtual void GetAlbums() {}
virtual void GetSongs() {}
virtual void ResetArtistsRequest() {}
virtual void ResetAlbumsRequest() {}
virtual void ResetSongsRequest() {}
signals:
void Login();
void Logout();
void Login(const QString &username, const QString &password, const QString &token);
void Login(const QString &hostname, const int, const QString &username, const QString &password);
void LoginSuccess();
void LoginFailure(QString failure_reason);
void LoginComplete(bool success, QString error = QString());
void TestSuccess();
void TestFailure(QString failure_reason);
void TestComplete(bool success, QString error = QString());
void Error(QString message);
void Results(SongList songs);
void UpdateStatus(QString text);

View File

@@ -0,0 +1,124 @@
/*
* 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 <QtGlobal>
#include <QWidget>
#include <QString>
#include <QStackedWidget>
#include <QContextMenuEvent>
#include <QSortFilterProxyModel>
#include "core/application.h"
#include "collection/collectionbackend.h"
#include "collection/collectionfilterwidget.h"
#include "internetservice.h"
#include "internetsongsview.h"
#include "ui_internetcollectionviewcontainer.h"
InternetSongsView::InternetSongsView(Application *app, InternetService *service, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent)
: QWidget(parent),
app_(app),
service_(service),
settings_group_(settings_group),
settings_page_(settings_page),
ui_(new Ui_InternetCollectionViewContainer)
{
ui_->setupUi(this);
ui_->stacked->setCurrentWidget(ui_->internetcollection_page);
ui_->view->Init(app_, service_->songs_collection_backend(), service_->songs_collection_model());
ui_->view->setModel(service_->songs_collection_sort_model());
ui_->view->SetFilter(ui_->filter);
ui_->filter->SetSettingsGroup(settings_group);
ui_->filter->SetCollectionModel(service_->songs_collection_model());
connect(ui_->view, SIGNAL(GetSongs()), SLOT(GetSongs()));
connect(ui_->view, SIGNAL(RemoveSongs(const SongList&)), service_, SIGNAL(RemoveSongs(const SongList&)));
connect(ui_->refresh, SIGNAL(clicked()), SLOT(GetSongs()));
connect(ui_->close, SIGNAL(clicked()), SLOT(AbortGetSongs()));
connect(ui_->abort, SIGNAL(clicked()), SLOT(AbortGetSongs()));
connect(service_, SIGNAL(SongsResults(SongList)), SLOT(SongsFinished(SongList)));
connect(service_, SIGNAL(SongsError(QString)), SLOT(SongsError(QString)));
connect(service_, SIGNAL(SongsUpdateStatus(QString)), ui_->status, SLOT(setText(QString)));
connect(service_, SIGNAL(SongsProgressSetMaximum(int)), ui_->progressbar, SLOT(setMaximum(int)));
connect(service_, SIGNAL(SongsUpdateProgress(int)), ui_->progressbar, SLOT(setValue(int)));
connect(service_->songs_collection_model(), SIGNAL(TotalArtistCountUpdated(int)), ui_->view, SLOT(TotalArtistCountUpdated(int)));
connect(service_->songs_collection_model(), SIGNAL(TotalAlbumCountUpdated(int)), ui_->view, SLOT(TotalAlbumCountUpdated(int)));
connect(service_->songs_collection_model(), SIGNAL(TotalSongCountUpdated(int)), ui_->view, SLOT(TotalSongCountUpdated(int)));
connect(service_->songs_collection_model(), SIGNAL(modelAboutToBeReset()), ui_->view, SLOT(SaveFocus()));
connect(service_->songs_collection_model(), SIGNAL(modelReset()), ui_->view, SLOT(RestoreFocus()));
ReloadSettings();
}
InternetSongsView::~InternetSongsView() { delete ui_; }
void InternetSongsView::ReloadSettings() {}
void InternetSongsView::contextMenuEvent(QContextMenuEvent *e) {}
void InternetSongsView::GetSongs() {
if (!service_->authenticated() && service_->oauth()) {
service_->ShowConfig();
return;
}
ui_->status->clear();
ui_->progressbar->show();
ui_->abort->show();
ui_->close->hide();
ui_->stacked->setCurrentWidget(ui_->help_page);
service_->GetSongs();
}
void InternetSongsView::AbortGetSongs() {
service_->ResetSongsRequest();
ui_->progressbar->setValue(0);
ui_->status->clear();
ui_->stacked->setCurrentWidget(ui_->internetcollection_page);
}
void InternetSongsView::SongsError(QString error) {
ui_->status->setText(error);
ui_->progressbar->setValue(0);
ui_->progressbar->hide();
ui_->abort->hide();
ui_->close->show();
}
void InternetSongsView::SongsFinished(SongList songs) {
service_->songs_collection_backend()->DeleteAll();
ui_->stacked->setCurrentWidget(ui_->internetcollection_page);
ui_->status->clear();
service_->songs_collection_backend()->AddOrUpdateSongs(songs);
}

View File

@@ -0,0 +1,67 @@
/*
* 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 INTERNETSONGSVIEW_H
#define INTERNETSONGSVIEW_H
#include "config.h"
#include <QWidget>
#include <QString>
#include "settings/settingsdialog.h"
#include "internetcollectionviewcontainer.h"
#include "ui_internetcollectionviewcontainer.h"
#include "core/song.h"
class QContextMenuEvent;
class Application;
class InternetService;
class Ui_InternetCollectionViewContainer;
class InternetCollectionView;
class InternetSongsView : public QWidget {
Q_OBJECT
public:
InternetSongsView(Application *app, InternetService *service, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent = nullptr);
~InternetSongsView();
void ReloadSettings();
InternetCollectionView *view() const { return ui_->view; }
private slots:
void contextMenuEvent(QContextMenuEvent *e);
void GetSongs();
void AbortGetSongs();;
void SongsError(QString error);
void SongsFinished(SongList songs);
private:
Application *app_;
InternetService *service_;
QString settings_group_;
SettingsDialog::Page settings_page_;
Ui_InternetCollectionViewContainer *ui_;
};
#endif // INTERNETSONGSVIEW_H

View File

@@ -33,7 +33,7 @@
#include "internettabsview.h"
#include "ui_internettabsview.h"
InternetTabsView::InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, QString settings_group, SettingsDialog::Page settings_page, QWidget *parent)
InternetTabsView::InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent)
: QWidget(parent),
app_(app),
service_(service),

View File

@@ -44,7 +44,7 @@ class InternetTabsView : public QWidget {
Q_OBJECT
public:
InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, QString settings_group, SettingsDialog::Page settings_page, QWidget *parent = nullptr);
InternetTabsView(Application *app, InternetService *service, InternetSearch *engine, const QString &settings_group, const SettingsDialog::Page settings_page, QWidget *parent = nullptr);
~InternetTabsView();
void ReloadSettings();

View File

@@ -68,6 +68,9 @@
#ifdef HAVE_MOODBAR
# include "moodbarsettingspage.h"
#endif
#ifdef HAVE_SUBSONIC
# include "subsonicsettingspage.h"
#endif
#include "ui_settingsdialog.h"
@@ -140,12 +143,15 @@ SettingsDialog::SettingsDialog(Application *app, QWidget *parent)
AddPage(Page_Moodbar, new MoodbarSettingsPage(this), iface);
#endif
#if defined(HAVE_TIDAL)
#if defined(HAVE_TIDAL) || defined(HAVE_SUBSONIC)
QTreeWidgetItem *streaming = AddCategory(tr("Streaming"));
#endif
#ifdef HAVE_TIDAL
AddPage(Page_Tidal, new TidalSettingsPage(this), streaming);
#endif
#ifdef HAVE_SUBSONIC
AddPage(Page_Subsonic, new SubsonicSettingsPage(this), streaming);
#endif
// List box
connect(ui_->list, SIGNAL(currentItemChanged(QTreeWidgetItem*,QTreeWidgetItem*)), SLOT(CurrentItemChanged(QTreeWidgetItem*)));

View File

@@ -83,6 +83,7 @@ class SettingsDialog : public QDialog {
Page_Proxy,
Page_Scrobbler,
Page_Tidal,
Page_Subsonic,
Page_Moodbar,
};

View File

@@ -0,0 +1,132 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 <QString>
#include <QSettings>
#include <QMessageBox>
#include <QEvent>
#include "subsonicsettingspage.h"
#include "ui_subsonicsettingspage.h"
#include "core/application.h"
#include "core/iconloader.h"
#include "internet/internetservices.h"
#include "subsonic/subsonicservice.h"
const char *SubsonicSettingsPage::kSettingsGroup = "Subsonic";
SubsonicSettingsPage::SubsonicSettingsPage(SettingsDialog *parent)
: SettingsPage(parent),
ui_(new Ui::SubsonicSettingsPage),
service_(dialog()->app()->internet_services()->Service<SubsonicService>()) {
ui_->setupUi(this);
setWindowIcon(IconLoader::Load("subsonic"));
connect(ui_->button_test, SIGNAL(clicked()), SLOT(TestClicked()));
connect(this, SIGNAL(Test(QString, int, QString, QString)), service_, SLOT(SendPing(QString, int, QString, QString)));
connect(service_, SIGNAL(TestFailure(QString)), SLOT(TestFailure(QString)));
connect(service_, SIGNAL(TestSuccess()), SLOT(TestSuccess()));
dialog()->installEventFilter(this);
}
SubsonicSettingsPage::~SubsonicSettingsPage() { delete ui_; }
void SubsonicSettingsPage::Load() {
QSettings s;
s.beginGroup(kSettingsGroup);
ui_->enable->setChecked(s.value("enabled", false).toBool());
ui_->hostname->setText(s.value("hostname").toString());
ui_->port->setText(QString::number(s.value("port", 4040).toInt()));
ui_->username->setText(s.value("username").toString());
QByteArray password = s.value("password").toByteArray();
if (password.isEmpty()) ui_->password->clear();
else ui_->password->setText(QString::fromUtf8(QByteArray::fromBase64(password)));
ui_->checkbox_verify_certificate->setChecked(s.value("verifycertificate", false).toBool());
ui_->checkbox_cache_album_covers->setChecked(s.value("cachealbumcovers", true).toBool());
s.endGroup();
}
void SubsonicSettingsPage::Save() {
QSettings s;
s.beginGroup(kSettingsGroup);
s.setValue("enabled", ui_->enable->isChecked());
s.setValue("hostname", ui_->hostname->text());
s.setValue("port", ui_->port->text().toInt());
s.setValue("username", ui_->username->text());
s.setValue("password", QString::fromUtf8(ui_->password->text().toUtf8().toBase64()));
s.setValue("verifycertificate", ui_->checkbox_verify_certificate->isChecked());
s.setValue("cachealbumcovers", ui_->checkbox_cache_album_covers->isChecked());
s.endGroup();
service_->ReloadSettings();
}
void SubsonicSettingsPage::TestClicked() {
if (ui_->hostname->text().isEmpty() || ui_->username->text().isEmpty() || ui_->password->text().isEmpty()) {
QMessageBox::critical(this, tr("Configuration incomplete"), tr("Missing hostname, username or password."));
return;
}
emit Test(ui_->hostname->text(), ui_->port->text().toInt(), ui_->username->text(), ui_->password->text());
ui_->button_test->setEnabled(false);
}
bool SubsonicSettingsPage::eventFilter(QObject *object, QEvent *event) {
if (object == dialog() && event->type() == QEvent::Enter) {
ui_->button_test->setEnabled(true);
return false;
}
return SettingsPage::eventFilter(object, event);
}
void SubsonicSettingsPage::TestSuccess() {
if (!this->isVisible()) return;
ui_->button_test->setEnabled(true);
QMessageBox::information(this, tr("Test successful!"), tr("Test successful!"));
}
void SubsonicSettingsPage::TestFailure(QString failure_reason) {
if (!this->isVisible()) return;
ui_->button_test->setEnabled(true);
QMessageBox::warning(this, tr("Test failed!"), failure_reason);
}

View File

@@ -0,0 +1,60 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 SUBSONICSETTINGSPAGE_H
#define SUBSONICSETTINGSPAGE_H
#include <QObject>
#include <QString>
#include <QEvent>
#include "settings/settingspage.h"
class SubsonicService;
class Ui_SubsonicSettingsPage;
class SubsonicSettingsPage : public SettingsPage {
Q_OBJECT
public:
explicit SubsonicSettingsPage(SettingsDialog* parent = nullptr);
~SubsonicSettingsPage();
static const char *kSettingsGroup;
void Load();
void Save();
bool eventFilter(QObject *object, QEvent *event);
signals:
void Test();
void Test(const QString &hostname, const int port, const QString &username, const QString &password);
private slots:
void TestClicked();
void TestSuccess();
void TestFailure(QString failure_reason);
private:
Ui_SubsonicSettingsPage* ui_;
SubsonicService *service_;
};
#endif // SUBSONICSETTINGSPAGE_H

View File

@@ -0,0 +1,223 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SubsonicSettingsPage</class>
<widget class="QWidget" name="SubsonicSettingsPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>715</width>
<height>836</height>
</rect>
</property>
<property name="windowTitle">
<string>Subsonic</string>
</property>
<layout class="QVBoxLayout" name="layout_subsonicsettingspage">
<item>
<widget class="QCheckBox" name="enable">
<property name="text">
<string>Enable</string>
</property>
</widget>
</item>
<item>
<widget class="Line" name="line_1">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="server_group">
<property name="title">
<string>Server</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="layout_server">
<item>
<widget class="QLabel" name="label_server_url">
<property name="text">
<string>Hostname</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="hostname"/>
</item>
<item>
<widget class="QLabel" name="label_port">
<property name="text">
<string>Port</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="port">
<property name="maximumSize">
<size>
<width>50</width>
<height>16777215</height>
</size>
</property>
</widget>
</item>
<item>
<spacer name="spacer_server">
<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>
</widget>
</item>
<item>
<widget class="QGroupBox" name="credential_group">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Authentication</string>
</property>
<layout class="QFormLayout" name="layout_credential_group">
<item row="1" column="0">
<widget class="QLabel" name="label_username">
<property name="text">
<string>Username</string>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="username"/>
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_password">
<property name="text">
<string>Password</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="password">
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_test">
<property name="text">
<string>Test</string>
</property>
</widget>
</item>
<item>
<widget class="Line" name="line_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupbox_preferences">
<property name="title">
<string>Preferences</string>
</property>
<layout class="QFormLayout" name="layout_preferences"/>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkbox_verify_certificate">
<property name="text">
<string>Verify server certificate</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkbox_cache_album_covers">
<property name="text">
<string>Cache album covers</string>
</property>
</widget>
</item>
<item>
<spacer name="spacer_middle">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>30</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="layout_bottom">
<item>
<spacer name="spacer_bottom">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="label_subsonic">
<property name="minimumSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>64</width>
<height>64</height>
</size>
</property>
<property name="pixmap">
<pixmap resource="../../data/icons.qrc">:/icons/64x64/subsonic.png</pixmap>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<tabstops>
<tabstop>username</tabstop>
<tabstop>password</tabstop>
</tabstops>
<resources>
<include location="../../data/data.qrc"/>
<include location="../../data/icons.qrc"/>
</resources>
<connections/>
</ui>

View File

@@ -0,0 +1,207 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 <QPair>
#include <QList>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QSslConfiguration>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include "core/logging.h"
#include "core/network.h"
#include "subsonicservice.h"
#include "subsonicbaserequest.h"
SubsonicBaseRequest::SubsonicBaseRequest(SubsonicService *service, NetworkAccessManager *network, QObject *parent) :
QObject(parent),
service_(service),
network_(network)
{}
SubsonicBaseRequest::~SubsonicBaseRequest() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
disconnect(reply, 0, nullptr, 0);
if (reply->isRunning()) reply->abort();
reply->deleteLater();
}
}
QUrl SubsonicBaseRequest::CreateUrl(const QString &ressource_name, const QList<Param> &params_provided) {
ParamList params = ParamList() << params_provided
<< Param("c", client_name())
<< Param("v", api_version())
<< Param("f", "json")
<< Param("u", username())
<< Param("p", QString("enc:" + password().toUtf8().toHex()));
QUrlQuery url_query;
for (const Param& param : params) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
url_query.addQueryItem(encoded_param.first, encoded_param.second);
}
QUrl url;
url.setScheme("https");
url.setHost(hostname());
if (port() > 0 && port() != 443)
url.setPort(port());
url.setPath(QString("/rest/") + ressource_name);
url.setQuery(url_query);
return url;
}
QNetworkReply *SubsonicBaseRequest::CreateGetRequest(const QString &ressource_name, const QList<Param> &params_provided) {
QUrl url = CreateUrl(ressource_name, params_provided);
QNetworkRequest req(url);
if (!verify_certificate()) {
QSslConfiguration sslconfig = QSslConfiguration::defaultConfiguration();
sslconfig.setPeerVerifyMode(QSslSocket::VerifyNone);
req.setSslConfiguration(sslconfig);
}
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QNetworkReply *reply = network_->get(req);
replies_ << reply;
//qLog(Debug) << "Subsonic: Sending request" << url;
return reply;
}
QByteArray SubsonicBaseRequest::GetReplyData(QNetworkReply *reply, QString &error) {
if (replies_.contains(reply)) {
replies_.removeAll(reply);
reply->deleteLater();
}
QByteArray data;
if (reply->error() == QNetworkReply::NoError) {
data = reply->readAll();
}
else {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
// See if there is Json data containing "error" - then use that instead.
data = reply->readAll();
QJsonParseError parse_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error);
QString failure_reason;
if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains("error")) {
QJsonValue json_error = json_obj["error"];
if (json_error.isObject()) {
json_obj = json_error.toObject();
if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) {
int code = json_obj["code"].toInt();
QString message = json_obj["message"].toString();
failure_reason = QString("%1 (%2)").arg(message).arg(code);
}
}
}
}
if (failure_reason.isEmpty()) {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
error = Error(failure_reason);
}
return QByteArray();
}
return data;
}
QJsonObject SubsonicBaseRequest::ExtractJsonObj(QByteArray &data, QString &error) {
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
error = Error("Reply from server missing Json data.", data);
return QJsonObject();
}
if (json_doc.isNull() || json_doc.isEmpty()) {
error = Error("Received empty Json document.", data);
return QJsonObject();
}
if (!json_doc.isObject()) {
error = Error("Json document is not an object.", json_doc);
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
error = Error("Received empty Json object.", json_doc);
return QJsonObject();
}
if (!json_obj.contains("subsonic-response")) {
error = Error("Json reply is missing subsonic-response.", json_obj);
return QJsonObject();
}
QJsonValue json_response = json_obj["subsonic-response"];
if (!json_response.isObject()) {
error = Error("Json response is not an object.", json_response);
return QJsonObject();
}
json_obj = json_response.toObject();
return json_obj;
}
QString SubsonicBaseRequest::Error(QString error, QVariant debug) {
qLog(Error) << "Subsonic:" << error;
if (debug.isValid()) qLog(Debug) << debug;
return error;
}

View File

@@ -0,0 +1,86 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 SUBSONICBASEREQUEST_H
#define SUBSONICBASEREQUEST_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QList>
#include <QPair>
#include <QString>
#include <QUrl>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include "core/song.h"
#include "internet/internetservices.h"
#include "internet/internetservice.h"
#include "internet/internetsearch.h"
#include "subsonicservice.h"
class Application;
class NetworkAccessManager;
class SubsonicUrlHandler;
class CollectionBackend;
class CollectionModel;
class SubsonicBaseRequest : public QObject {
Q_OBJECT
public:
SubsonicBaseRequest(SubsonicService *service, NetworkAccessManager *network, QObject *parent);
~SubsonicBaseRequest();
typedef QPair<QString, QString> Param;
typedef QList<Param> ParamList;
typedef QPair<QByteArray, QByteArray> EncodedParam;
typedef QList<EncodedParam> EncodedParamList;
QUrl CreateUrl(const QString &ressource_name, const QList<Param> &params_provided);
QNetworkReply *CreateGetRequest(const QString &ressource_name, const QList<Param> &params_provided);
QByteArray GetReplyData(QNetworkReply *reply, QString &error);
QJsonObject ExtractJsonObj(QByteArray &data, QString &error);
virtual QString Error(QString error, QVariant debug = QVariant());
QString client_name() { return service_->client_name(); }
QString api_version() { return service_->api_version(); }
QString hostname() { return service_->hostname(); }
int port() { return service_->port(); }
QString username() { return service_->username(); }
QString password() { return service_->password(); }
bool verify_certificate() { return service_->verify_certificate(); }
bool cache_album_covers() { return service_->cache_album_covers(); }
private:
SubsonicService *service_;
NetworkAccessManager *network_;
QList<QNetworkReply*> replies_;
};
#endif // SUBSONICBASEREQUEST_H

View File

@@ -0,0 +1,759 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 <assert.h>
#include <QObject>
#include <QByteArray>
#include <QDir>
#include <QString>
#include <QUrl>
#include <QImage>
#include <QNetworkReply>
#include <QSslConfiguration>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include <QMimeDatabase>
#include "core/closure.h"
#include "core/logging.h"
#include "core/network.h"
#include "core/song.h"
#include "core/timeconstants.h"
#include "organise/organiseformat.h"
#include "subsonicservice.h"
#include "subsonicurlhandler.h"
#include "subsonicrequest.h"
const int SubsonicRequest::kMaxConcurrentAlbumsRequests = 3;
const int SubsonicRequest::kMaxConcurrentAlbumSongsRequests = 3;
const int SubsonicRequest::kMaxConcurrentAlbumCoverRequests = 1;
SubsonicRequest::SubsonicRequest(SubsonicService *service, SubsonicUrlHandler *url_handler, NetworkAccessManager *network, QObject *parent)
: SubsonicBaseRequest(service, network, parent),
service_(service),
url_handler_(url_handler),
network_(network),
finished_(false),
albums_requests_active_(0),
album_songs_requests_active_(0),
album_songs_requested_(0),
album_songs_received_(0),
album_covers_requests_active_(),
album_covers_requested_(0),
album_covers_received_(0),
no_results_(false)
{}
SubsonicRequest::~SubsonicRequest() {
while (!album_cover_replies_.isEmpty()) {
QNetworkReply *reply = album_cover_replies_.takeFirst();
disconnect(reply, 0, nullptr, 0);
if (reply->isRunning()) reply->abort();
reply->deleteLater();
}
}
void SubsonicRequest::Reset() {
finished_ = false;
albums_requests_queue_.clear();
album_songs_requests_queue_.clear();
album_cover_requests_queue_.clear();
album_songs_requests_pending_.clear();
album_covers_requests_sent_.clear();
albums_requests_active_ = 0;
album_songs_requests_active_ = 0;
album_songs_requested_ = 0;
album_songs_received_ = 0;
album_covers_requests_active_ = 0;
album_covers_requested_ = 0;
album_covers_received_ = 0;
songs_.clear();
errors_.clear();
no_results_ = false;
album_cover_replies_.clear();
}
void SubsonicRequest::GetAlbums() {
emit UpdateStatus(tr("Retrieving albums..."));
emit UpdateProgress(0);
AddAlbumsRequest();
}
void SubsonicRequest::AddAlbumsRequest(const int offset, const int size) {
Request request;
request.size = size;
request.offset = offset;
albums_requests_queue_.enqueue(request);
if (albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests();
}
void SubsonicRequest::FlushAlbumsRequests() {
while (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) {
Request request = albums_requests_queue_.dequeue();
++albums_requests_active_;
ParamList params = ParamList() << Param("type", "alphabeticalByName");
if (request.size > 0) params << Param("size", QString::number(request.size));
if (request.offset > 0) params << Param("offset", QString::number(request.offset));
QNetworkReply *reply;
reply = CreateGetRequest(QString("getAlbumList2"), params);
NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumsReplyReceived(QNetworkReply*, int)), reply, request.offset);
}
}
void SubsonicRequest::AlbumsReplyReceived(QNetworkReply *reply, const int offset_requested) {
--albums_requests_active_;
QString error;
QByteArray data = GetReplyData(reply, error);
if (finished_) return;
if (data.isEmpty()) {
AlbumsFinishCheck(offset_requested);
return;
}
QJsonObject json_obj = ExtractJsonObj(data, error);
if (json_obj.isEmpty()) {
AlbumsFinishCheck(offset_requested);
return;
}
if (json_obj.contains("error")) {
QJsonValue json_error = json_obj["error"];
if (!json_error.isObject()) {
Error("Json error is not an object.", json_obj);
AlbumsFinishCheck(offset_requested);
return;
}
json_obj = json_error.toObject();
if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) {
int code = json_obj["code"].toInt();
QString message = json_obj["message"].toString();
Error(QString("%1 (%2)").arg(message).arg(code));
AlbumsFinishCheck(offset_requested);
}
else {
Error("Json error object missing code or message.", json_obj);
AlbumsFinishCheck(offset_requested);
return;
}
return;
}
if (!json_obj.contains("albumList") && !json_obj.contains("albumList2")) {
error = Error("Json reply is missing albumList.", json_obj);
AlbumsFinishCheck(offset_requested);
return;
}
QJsonValue json_albumlist;
if (json_obj.contains("albumList")) json_albumlist = json_obj["albumList"];
else if (json_obj.contains("albumList2")) json_albumlist = json_obj["albumList2"];
if (!json_albumlist.isObject()) {
error = Error("Json album list is not an object.", json_albumlist);
AlbumsFinishCheck(offset_requested);
}
json_obj = json_albumlist.toObject();
if (json_obj.isEmpty()) {
if (offset_requested == 0) no_results_ = true;
AlbumsFinishCheck(offset_requested);
return;
}
if (!json_obj.contains("album")) {
error = Error("Json album list does not contain album array.", json_obj);
AlbumsFinishCheck(offset_requested);
}
QJsonValue json_album = json_obj["album"];
if (json_album.isNull()) {
if (offset_requested == 0) no_results_ = true;
AlbumsFinishCheck(offset_requested);
return;
}
if (!json_album.isArray()) {
error = Error("Json album is not an array.", json_album);
AlbumsFinishCheck(offset_requested);
}
QJsonArray json_albums = json_album.toArray();
if (json_albums.isEmpty()) {
if (offset_requested == 0) no_results_ = true;
AlbumsFinishCheck(offset_requested);
return;
}
int albums_received = 0;
for (const QJsonValue &value : json_albums) {
++albums_received;
if (!value.isObject()) {
Error("Invalid Json reply, album is not an object.", value);
continue;
}
QJsonObject json_obj = value.toObject();
if (!json_obj.contains("id") || !json_obj.contains("artist")) {
Error("Invalid Json reply, album object is missing ID or artist.", json_obj);
continue;
}
if (!json_obj.contains("album") && !json_obj.contains("name")) {
Error("Invalid Json reply, album object is missing album or name.", json_obj);
continue;
}
int album_id = json_obj["id"].toString().toInt();
QString artist = json_obj["artist"].toString();
QString album;
if (json_obj.contains("album")) album = json_obj["album"].toString();
else if (json_obj.contains("name")) album = json_obj["name"].toString();
if (album_songs_requests_pending_.contains(album_id)) continue;
Request request;
request.album_id = album_id;
request.album_artist = artist;
album_songs_requests_pending_.insert(album_id, request);
}
AlbumsFinishCheck(offset_requested, albums_received);
}
void SubsonicRequest::AlbumsFinishCheck(const int offset, const int albums_received) {
if (finished_) return;
if (albums_received > 0) {
int offset_next = offset + albums_received;
if (offset_next > 0) {
AddAlbumsRequest(offset_next);
}
}
if (!albums_requests_queue_.isEmpty() && albums_requests_active_ < kMaxConcurrentAlbumsRequests) FlushAlbumsRequests();
if (albums_requests_queue_.isEmpty() && albums_requests_active_ <= 0) { // Albums list is finished, get songs for all albums.
QHash<int, Request> ::iterator i;
for (i = album_songs_requests_pending_.begin() ; i != album_songs_requests_pending_.end() ; ++i) {
Request request = i.value();
AddAlbumSongsRequest(request.artist_id, request.album_id, request.album_artist);
}
album_songs_requests_pending_.clear();
if (album_songs_requested_ > 0) {
if (album_songs_requested_ == 1) emit UpdateStatus(tr("Retrieving songs for %1 album...").arg(album_songs_requested_));
else emit UpdateStatus(tr("Retrieving songs for %1 albums...").arg(album_songs_requested_));
emit ProgressSetMaximum(album_songs_requested_);
emit UpdateProgress(0);
}
}
FinishCheck();
}
void SubsonicRequest::AddAlbumSongsRequest(const int artist_id, const int album_id, const QString &album_artist, const int offset) {
Request request;
request.artist_id = artist_id;
request.album_id = album_id;
request.album_artist = album_artist;
request.offset = offset;
album_songs_requests_queue_.enqueue(request);
++album_songs_requested_;
if (album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests();
}
void SubsonicRequest::FlushAlbumSongsRequests() {
while (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) {
Request request = album_songs_requests_queue_.dequeue();
++album_songs_requests_active_;
ParamList params = ParamList() << Param("id", QString::number(request.album_id));
QNetworkReply *reply = CreateGetRequest(QString("getAlbum"), params);
NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumSongsReplyReceived(QNetworkReply*, int, int, QString)), reply, request.artist_id, request.album_id, request.album_artist);
}
}
void SubsonicRequest::AlbumSongsReplyReceived(QNetworkReply *reply, const int artist_id, const int album_id, const QString album_artist) {
--album_songs_requests_active_;
++album_songs_received_;
emit UpdateProgress(album_songs_received_);
QString error;
QByteArray data = GetReplyData(reply, error);
if (finished_) return;
if (data.isEmpty()) {
SongsFinishCheck();
return;
}
QJsonObject json_obj = ExtractJsonObj(data, error);
if (json_obj.isEmpty()) {
SongsFinishCheck();
return;
}
if (json_obj.contains("error")) {
QJsonValue json_error = json_obj["error"];
if (!json_error.isObject()) {
Error("Json error is not an object.", json_obj);
SongsFinishCheck();
return;
}
json_obj = json_error.toObject();
if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) {
int code = json_obj["code"].toInt();
QString message = json_obj["message"].toString();
Error(QString("%1 (%2)").arg(message).arg(code));
SongsFinishCheck();
}
else {
Error("Json error object missing code or message.", json_obj);
SongsFinishCheck();
}
return;
}
if (!json_obj.contains("album")) {
error = Error("Json reply is missing albumList.", json_obj);
SongsFinishCheck();
return;
}
QJsonValue json_album = json_obj["album"];
if (!json_album.isObject()) {
error = Error("Json album is not an object.", json_album);
SongsFinishCheck();
return;
}
QJsonObject json_album_obj = json_album.toObject();
if (!json_album_obj.contains("song")) {
error = Error("Json album object does not contain song array.", json_obj);
SongsFinishCheck();
return;
}
QJsonValue json_song = json_album_obj["song"];
if (!json_song.isArray()) {
error = Error("Json song is not an array.", json_album_obj);
SongsFinishCheck();
return;
}
QJsonArray json_array = json_song.toArray();
bool compilation = false;
bool multidisc = false;
SongList songs;
int songs_received = 0;
for (const QJsonValue &value : json_array) {
if (!value.isObject()) {
Error("Invalid Json reply, track is not a object.", value);
continue;
}
QJsonObject json_obj = value.toObject();
++songs_received;
Song song;
ParseSong(song, json_obj, artist_id, album_id, album_artist);
if (!song.is_valid()) continue;
if (song.disc() >= 2) multidisc = true;
if (song.is_compilation()) compilation = true;
songs << song;
}
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);
}
songs_ << song;
}
SongsFinishCheck();
}
void SubsonicRequest::SongsFinishCheck() {
if (finished_) return;
if (!album_songs_requests_queue_.isEmpty() && album_songs_requests_active_ < kMaxConcurrentAlbumSongsRequests) FlushAlbumSongsRequests();
if (
cache_album_covers() &&
album_songs_requests_queue_.isEmpty() &&
album_songs_requests_active_ <= 0 &&
album_cover_requests_queue_.isEmpty() &&
album_covers_received_ <= 0 &&
album_covers_requests_sent_.isEmpty() &&
album_songs_received_ >= album_songs_requested_
) {
GetAlbumCovers();
}
FinishCheck();
}
int SubsonicRequest::ParseSong(Song &song, const QJsonObject &json_obj, const int artist_id_requested, const int album_id_requested, const QString &album_artist) {
if (
!json_obj.contains("id") ||
!json_obj.contains("title") ||
!json_obj.contains("album") ||
!json_obj.contains("artist") ||
!json_obj.contains("size") ||
!json_obj.contains("contentType") ||
!json_obj.contains("suffix") ||
!json_obj.contains("duration") ||
!json_obj.contains("bitRate") ||
!json_obj.contains("albumId") ||
!json_obj.contains("artistId") ||
!json_obj.contains("type")
) {
Error("Invalid Json reply, song is missing one or more values.", json_obj);
return -1;
}
int song_id = json_obj["id"].toString().toInt();
int album_id = json_obj["albumId"].toString().toInt();
int artist_id = json_obj["artistId"].toString().toInt();
QString title = json_obj["title"].toString();
title.remove(Song::kTitleRemoveMisc);
QString album = json_obj["album"].toString();
QString artist = json_obj["artist"].toString();
int size = json_obj["size"].toInt();
QString mimetype = json_obj["contentType"].toString();
quint64 duration = json_obj["duration"].toInt() * kNsecPerSec;
int bitrate = json_obj["bitRate"].toInt();
int year = 0;
if (json_obj.contains("year")) year = json_obj["year"].toInt();
int disc = 0;
if (json_obj.contains("disc")) disc = json_obj["disc"].toString().toInt();
int track = 0;
if (json_obj.contains("track")) track = json_obj["track"].toInt();
QString genre;
if (json_obj.contains("genre")) genre = json_obj["genre"].toString();
int cover_art_id = -1;
if (json_obj.contains("coverArt")) cover_art_id = json_obj["coverArt"].toString().toInt();
QUrl url;
url.setScheme(url_handler_->scheme());
url.setPath(QString::number(song_id));
QUrl cover_url;
if (cover_art_id != -1) {
const ParamList params = ParamList() << Param("id", QString::number(cover_art_id));
cover_url = CreateUrl("getCoverArt", params);
}
Song::FileType filetype(Song::FileType_Stream);
QMimeDatabase mimedb;
for (QString suffix : mimedb.mimeTypeForName(mimetype.toUtf8()).suffixes()) {
filetype = Song::FiletypeByExtension(suffix);
if (filetype != Song::FileType_Unknown) break;
}
if (filetype == Song::FileType_Unknown) {
qLog(Debug) << "Subsonic: Unknown mimetype" << mimetype;
filetype = Song::FileType_Stream;
}
song.set_source(Song::Source_Subsonic);
song.set_song_id(song_id);
song.set_album_id(album_id);
song.set_artist_id(artist_id);
if (album_artist != artist) song.set_albumartist(album_artist);
song.set_album(album);
song.set_artist(artist);
song.set_title(title);
if (track > 0) song.set_track(track);
if (disc > 0) song.set_disc(disc);
if (year > 0) song.set_year(year);
song.set_url(url);
song.set_length_nanosec(duration);
if (cover_url.isValid()) song.set_art_automatic(cover_url.toEncoded());
song.set_genre(genre);
song.set_directory_id(0);
song.set_filetype(filetype);
song.set_filesize(size);
song.set_mtime(0);
song.set_ctime(0);
song.set_bitrate(bitrate);
song.set_valid(true);
return song_id;
}
void SubsonicRequest::GetAlbumCovers() {
for (Song &song : songs_) {
if (!song.art_automatic().isEmpty()) AddAlbumCoverRequest(song);
}
FlushAlbumCoverRequests();
if (album_covers_requested_ == 1) emit UpdateStatus(tr("Retrieving album cover for %1 album...").arg(album_covers_requested_));
else emit UpdateStatus(tr("Retrieving album covers for %1 albums...").arg(album_covers_requested_));
emit ProgressSetMaximum(album_covers_requested_);
emit UpdateProgress(0);
}
void SubsonicRequest::AddAlbumCoverRequest(Song &song) {
QUrl url(song.art_automatic());
if (!url.isValid()) return;
if (album_covers_requests_sent_.contains(song.album_id())) {
album_covers_requests_sent_.insertMulti(song.album_id(), &song);
return;
}
album_covers_requests_sent_.insertMulti(song.album_id(), &song);
++album_covers_requested_;
AlbumCoverRequest request;
request.album_id = song.album_id();
request.url = url;
request.filename = AlbumCoverFileName(song);
album_cover_requests_queue_.enqueue(request);
}
QString SubsonicRequest::AlbumCoverFileName(const Song &song) {
QString artist = song.effective_albumartist();
QString album = song.effective_album();
QString title = song.title();
artist.remove('/');
album.remove('/');
title.remove('/');
QString filename = artist + "-" + album + ".jpg";
filename = filename.toLower();
filename.replace(' ', '-');
filename.replace("--", "-");
filename.replace(230, "ae");
filename.replace(198, "AE");
filename.replace(246, 'o');
filename.replace(248, 'o');
filename.replace(214, 'O');
filename.replace(216, 'O');
filename.replace(228, 'a');
filename.replace(229, 'a');
filename.replace(196, 'A');
filename.replace(197, 'A');
filename.remove(OrganiseFormat::kValidFatCharacters);
return filename;
}
void SubsonicRequest::FlushAlbumCoverRequests() {
while (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests) {
AlbumCoverRequest request = album_cover_requests_queue_.dequeue();
++album_covers_requests_active_;
QNetworkRequest req(request.url);
if (!verify_certificate()) {
QSslConfiguration sslconfig = QSslConfiguration::defaultConfiguration();
sslconfig.setPeerVerifyMode(QSslSocket::VerifyNone);
req.setSslConfiguration(sslconfig);
}
QNetworkReply *reply = network_->get(req);
album_cover_replies_ << reply;
NewClosure(reply, SIGNAL(finished()), this, SLOT(AlbumCoverReceived(QNetworkReply*, const int, const QUrl&, const QString&)), reply, request.album_id, request.url, request.filename);
}
}
void SubsonicRequest::AlbumCoverReceived(QNetworkReply *reply, const int album_id, const QUrl &url, const QString &filename) {
if (album_cover_replies_.contains(reply)) {
album_cover_replies_.removeAll(reply);
reply->deleteLater();
}
else {
AlbumCoverFinishCheck();
return;
}
--album_covers_requests_active_;
++album_covers_received_;
if (finished_) return;
emit UpdateProgress(album_covers_received_);
if (!album_covers_requests_sent_.contains(album_id)) {
AlbumCoverFinishCheck();
return;
}
QString error;
if (reply->error() != QNetworkReply::NoError) {
error = Error(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
album_covers_requests_sent_.remove(album_id);
AlbumCoverFinishCheck();
return;
}
QByteArray data = reply->readAll();
if (data.isEmpty()) {
error = Error(QString("Received empty image data for %1").arg(url.toString()));
album_covers_requests_sent_.remove(album_id);
AlbumCoverFinishCheck();
return;
}
QImage image;
if (image.loadFromData(data)) {
QDir dir;
if (dir.mkpath(service_->CoverCacheDir())) {
QString filepath(service_->CoverCacheDir() + "/" + filename);
if (image.save(filepath, "JPG")) {
while (album_covers_requests_sent_.contains(album_id)) {
Song *song = album_covers_requests_sent_.take(album_id);
song->set_art_automatic(filepath);
}
}
}
}
else {
album_covers_requests_sent_.remove(album_id);
error = Error(QString("Error decoding image data from %1").arg(url.toString()));
}
AlbumCoverFinishCheck();
}
void SubsonicRequest::AlbumCoverFinishCheck() {
if (!album_cover_requests_queue_.isEmpty() && album_covers_requests_active_ < kMaxConcurrentAlbumCoverRequests)
FlushAlbumCoverRequests();
FinishCheck();
}
void SubsonicRequest::FinishCheck() {
if (
!finished_ &&
albums_requests_queue_.isEmpty() &&
album_songs_requests_queue_.isEmpty() &&
album_cover_requests_queue_.isEmpty() &&
album_songs_requests_pending_.isEmpty() &&
album_covers_requests_sent_.isEmpty() &&
albums_requests_active_ <= 0 &&
album_songs_requests_active_ <= 0 &&
album_songs_received_ >= album_songs_requested_ &&
album_covers_requests_active_ <= 0 &&
album_covers_received_ >= album_covers_requested_
) {
finished_ = true;
if (songs_.isEmpty()) {
if (no_results_) emit Results(songs_);
else if (errors_.isEmpty()) emit ErrorSignal(tr("Unknown error"));
else emit ErrorSignal(errors_);
}
else {
emit Results(songs_);
}
}
}
QString SubsonicRequest::Error(QString error, QVariant debug) {
qLog(Error) << "Subsonic:" << error;
if (debug.isValid()) qLog(Debug) << debug;
if (!error.isEmpty()) {
errors_ += error;
errors_ += "<br />";
}
FinishCheck();
return error;
}
void SubsonicRequest::Warn(QString error, QVariant debug) {
qLog(Error) << "Subsonic:" << error;
if (debug.isValid()) qLog(Debug) << debug;
}

View File

@@ -0,0 +1,149 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 SUBSONICREQUEST_H
#define SUBSONICREQUEST_H
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QPair>
#include <QList>
#include <QHash>
#include <QMap>
#include <QMultiMap>
#include <QQueue>
#include <QString>
#include <QUrl>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonValue>
#include "core/song.h"
#include "subsonicbaserequest.h"
class NetworkAccessManager;
class SubsonicService;
class SubsonicUrlHandler;
class SubsonicRequest : public SubsonicBaseRequest {
Q_OBJECT
public:
SubsonicRequest(SubsonicService *service, SubsonicUrlHandler *url_handler, NetworkAccessManager *network, QObject *parent);
~SubsonicRequest();
void ReloadSettings();
void GetAlbums();
void Reset();
signals:
void Results(SongList songs);
void ErrorSignal(QString message);
void ErrorSignal(int id, QString message);
void UpdateStatus(QString text);
void ProgressSetMaximum(int max);
void UpdateProgress(int max);
private slots:
void AlbumsReplyReceived(QNetworkReply *reply, const int offset_requested);
void AlbumSongsReplyReceived(QNetworkReply *reply, const int artist_id, const int album_id, const QString album_artist);
void AlbumCoverReceived(QNetworkReply *reply, const int album_id, const QUrl &url, const QString &filename);
private:
typedef QPair<QString, QString> Param;
typedef QList<Param> ParamList;
struct Request {
int artist_id = 0;
int album_id = 0;
int song_id = 0;
int offset = 0;
int size = 0;
QString album_artist;
};
struct AlbumCoverRequest {
int artist_id = 0;
int album_id = 0;
QUrl url;
QString filename;
};
void AddAlbumsRequest(const int offset = 0, const int size = 0);
void FlushAlbumsRequests();
void AlbumsFinishCheck(const int offset = 0, const int albums_received = 0);
void SongsFinishCheck();
void AddAlbumSongsRequest(const int artist_id, const int album_id, const QString &album_artist, const int offset = 0);
QString AlbumCoverFileName(const Song &song);
void FlushAlbumSongsRequests();
int ParseSong(Song &song, const QJsonObject &json_obj, const int artist_id_requested = 0, const int album_id_requested = 0, const QString &album_artist = QString());
void GetAlbumCovers();
void AddAlbumCoverRequest(Song &song);
void FlushAlbumCoverRequests();
void AlbumCoverFinishCheck();
void FinishCheck();
void Warn(QString error, QVariant debug = QVariant());
QString Error(QString error, QVariant debug = QVariant());
static const int kMaxConcurrentAlbumsRequests;
static const int kMaxConcurrentArtistAlbumsRequests;
static const int kMaxConcurrentAlbumSongsRequests;
static const int kMaxConcurrentAlbumCoverRequests;
SubsonicService *service_;
SubsonicUrlHandler *url_handler_;
NetworkAccessManager *network_;
bool finished_;
QQueue<Request> albums_requests_queue_;
QQueue<Request> album_songs_requests_queue_;
QQueue<AlbumCoverRequest> album_cover_requests_queue_;
QHash<int, Request> album_songs_requests_pending_;
QMultiMap<int, Song*> album_covers_requests_sent_;
int albums_requests_active_;
int album_songs_requests_active_;
int album_songs_requested_;
int album_songs_received_;
int album_covers_requests_active_;
int album_covers_requested_;
int album_covers_received_;
SongList songs_;
QString errors_;
bool no_results_;
QList<QNetworkReply*> album_cover_replies_;
};
#endif // SUBSONICREQUEST_H

View File

@@ -0,0 +1,350 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 <stdbool.h>
#include <memory>
#include <QObject>
#include <QStandardPaths>
#include <QByteArray>
#include <QPair>
#include <QList>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QSortFilterProxyModel>
#include "core/application.h"
#include "core/player.h"
#include "core/closure.h"
#include "core/logging.h"
#include "core/network.h"
#include "core/database.h"
#include "core/song.h"
#include "internet/internetsearch.h"
#include "collection/collectionbackend.h"
#include "collection/collectionmodel.h"
#include "subsonicservice.h"
#include "subsonicurlhandler.h"
#include "subsonicrequest.h"
#include "settings/subsonicsettingspage.h"
using std::shared_ptr;
const Song::Source SubsonicService::kSource = Song::Source_Subsonic;
const char *SubsonicService::kClientName = "Strawberry";
const char *SubsonicService::kApiVersion = "1.16.1";
const char *SubsonicService::kSongsTable = "subsonic_songs";
const char *SubsonicService::kSongsFtsTable = "subsonic_songs_fts";
SubsonicService::SubsonicService(Application *app, QObject *parent)
: InternetService(Song::Source_Subsonic, "Subsonic", "subsonic", app, parent),
app_(app),
network_(new NetworkAccessManager(this)),
url_handler_(new SubsonicUrlHandler(app, this)),
collection_backend_(nullptr),
collection_model_(nullptr),
collection_sort_model_(new QSortFilterProxyModel(this)),
verify_certificate_(false),
cache_album_covers_(true)
{
app->player()->RegisterUrlHandler(url_handler_);
// Backend
collection_backend_ = new CollectionBackend();
collection_backend_->moveToThread(app_->database()->thread());
collection_backend_->Init(app_->database(), kSongsTable, QString(), QString(), kSongsFtsTable);
// Model
collection_model_ = new CollectionModel(collection_backend_, app_, this);
collection_sort_model_->setSourceModel(collection_model_);
collection_sort_model_->setSortRole(CollectionModel::Role_SortText);
collection_sort_model_->setDynamicSortFilter(true);
collection_sort_model_->setSortLocaleAware(true);
collection_sort_model_->sort(0);
ReloadSettings();
}
SubsonicService::~SubsonicService() {}
void SubsonicService::ShowConfig() {
app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Subsonic);
}
void SubsonicService::ReloadSettings() {
QSettings s;
s.beginGroup(SubsonicSettingsPage::kSettingsGroup);
hostname_ = s.value("hostname").toString();
port_ = s.value("port", 443).toInt();
username_ = s.value("username").toString();
QByteArray password = s.value("password").toByteArray();
if (password.isEmpty()) password_.clear();
else password_ = QString::fromUtf8(QByteArray::fromBase64(password));
verify_certificate_ = s.value("verifycertificate", false).toBool();
cache_album_covers_ = s.value("cachealbumcovers", true).toBool();
s.endGroup();
}
QString SubsonicService::CoverCacheDir() {
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + "/subsonicalbumcovers";
}
void SubsonicService::SendPing() {
SendPing(hostname_, port_, username_, password_);
}
void SubsonicService::SendPing(const QString &hostname, const int port, const QString &username, const QString &password) {
const ParamList params = ParamList() << Param("c", kClientName)
<< Param("v", kApiVersion)
<< Param("f", "json")
<< Param("u", username)
<< Param("p", QString("enc:" + password.toUtf8().toHex()));
QUrlQuery url_query;
for (const Param &param : params) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
url_query.addQueryItem(encoded_param.first, encoded_param.second);
}
QUrl url;
url.setScheme("https");
url.setHost(hostname);
url.setPort(port);
url.setPath("/rest/ping.view");
url.setQuery(url_query);
QNetworkRequest req(url);
if (!verify_certificate_) {
QSslConfiguration sslconfig = QSslConfiguration::defaultConfiguration();
sslconfig.setPeerVerifyMode(QSslSocket::VerifyNone);
req.setSslConfiguration(sslconfig);
}
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QNetworkReply *reply = network_->get(req);
NewClosure(reply, SIGNAL(finished()), this, SLOT(HandlePingReply(QNetworkReply*)), reply);
//qLog(Debug) << "Subsonic: Sending request" << url << query;
}
void SubsonicService::HandlePingReply(QNetworkReply *reply) {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
if (reply->error() < 200) {
// This is a network error, there is nothing more to do.
PingError(QString("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
return;
}
else {
// See if there is Json data containing "error" - then use that instead.
QByteArray data = reply->readAll();
QJsonParseError parse_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error);
QString failure_reason;
if (parse_error.error == QJsonParseError::NoError && !json_doc.isNull() && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (!json_obj.isEmpty() && json_obj.contains("error")) {
QJsonValue json_error = json_obj["error"];
if (json_error.isObject()) {
json_obj = json_error.toObject();
if (!json_obj.isEmpty() && json_obj.contains("code") && json_obj.contains("message")) {
int code = json_obj["code"].toInt();
QString message = json_obj["message"].toString();
failure_reason = QString("%1 (%2)").arg(message).arg(code);
}
}
}
}
if (failure_reason.isEmpty()) {
failure_reason = QString("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
PingError(failure_reason);
return;
}
}
QByteArray data(reply->readAll());
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
PingError("Ping reply from server missing Json data.");
return;
}
if (json_doc.isNull() || json_doc.isEmpty()) {
PingError("Ping reply from server has empty Json document.");
return;
}
if (!json_doc.isObject()) {
PingError("Ping reply from server has Json document that is not an object.", json_doc);
return;
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
PingError("Ping reply from server has empty Json object.", json_doc);
return;
}
if (!json_obj.contains("subsonic-response")) {
PingError("Ping reply from server is missing subsonic-response", json_obj);
return;
}
QJsonValue json_response = json_obj["subsonic-response"];
if (!json_response.isObject()) {
PingError("Ping reply from server subsonic-response is not an object", json_response);
return;
}
json_obj = json_response.toObject();
if (json_obj.contains("error")) {
QJsonValue json_error = json_obj["error"];
if (!json_error.isObject()) {
PingError("Authentication error reply from server is not an object", json_response);
return;
}
json_obj = json_error.toObject();
if (!json_obj.contains("code") || !json_obj.contains("message")) {
PingError("Authentication error reply from server is missing status or message", json_obj);
return;
}
//int status = json_obj["code"].toInt();
QString message = json_obj["message"].toString();
emit TestComplete(false, message);
emit TestFailure(message);
return;
}
if (!json_obj.contains("status")) {
PingError("Ping reply from server is missing status", json_obj);
return;
}
QString status = json_obj["status"].toString().toLower();
QString message = json_obj["message"].toString();
if (status == "failed") {
emit TestComplete(false, message);
emit TestFailure(message);
return;
}
else if (status == "ok") {
emit TestComplete(true);
emit TestSuccess();
return;
}
else {
PingError("Ping reply status from server is unknown", json_obj);
return;
}
}
void SubsonicService::CheckConfiguration() {
if (hostname_.isEmpty()) {
emit TestComplete(false, "Missing Subsonic hostname.");
return;
}
if (username_.isEmpty()) {
emit TestComplete(false, "Missing Subsonic username.");
return;
}
if (password_.isEmpty()) {
emit TestComplete(false, "Missing Subsonic password.");
return;
}
}
void SubsonicService::ResetSongsRequest() {
if (songs_request_.get()) {
disconnect(songs_request_.get(), 0, nullptr, 0);
disconnect(this, 0, songs_request_.get(), 0);
songs_request_.reset();
}
}
void SubsonicService::GetSongs() {
ResetSongsRequest();
songs_request_.reset(new SubsonicRequest(this, url_handler_, network_, this));
connect(songs_request_.get(), SIGNAL(ErrorSignal(QString)), SLOT(SongsErrorReceived(QString)));
connect(songs_request_.get(), SIGNAL(Results(SongList)), SLOT(SongsResultsReceived(SongList)));
connect(songs_request_.get(), SIGNAL(UpdateStatus(QString)), SIGNAL(SongsUpdateStatus(QString)));
connect(songs_request_.get(), SIGNAL(ProgressSetMaximum(int)), SIGNAL(SongsProgressSetMaximum(int)));
connect(songs_request_.get(), SIGNAL(UpdateProgress(int)), SIGNAL(SongsUpdateProgress(int)));
songs_request_->GetAlbums();
}
void SubsonicService::SongsResultsReceived(SongList songs) {
emit SongsResults(songs);
}
void SubsonicService::SongsErrorReceived(QString error) {
emit SongsError(error);
}
QString SubsonicService::PingError(QString error, QVariant debug) {
qLog(Error) << "Subsonic:" << error;
if (debug.isValid()) qLog(Debug) << debug;
emit TestFailure(error);
emit TestComplete(false, error);
return error;
}

View File

@@ -0,0 +1,130 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 SUBSONICSERVICE_H
#define SUBSONICSERVICE_H
#include "config.h"
#include <memory>
#include <stdbool.h>
#include <QtGlobal>
#include <QObject>
#include <QPair>
#include <QList>
#include <QString>
#include <QUrl>
#include <QNetworkReply>
#include <QTimer>
#include "core/song.h"
#include "internet/internetservice.h"
#include "internet/internetsearch.h"
#include "settings/subsonicsettingspage.h"
class QSortFilterProxyModel;
class Application;
class NetworkAccessManager;
class SubsonicUrlHandler;
class SubsonicRequest;
class CollectionBackend;
class CollectionModel;
using std::shared_ptr;
class SubsonicService : public InternetService {
Q_OBJECT
public:
SubsonicService(Application *app, QObject *parent);
~SubsonicService();
static const Song::Source kSource;
void ReloadSettings();
QString CoverCacheDir();
QString client_name() { return kClientName; }
QString api_version() { return kApiVersion; }
QString hostname() { return hostname_; }
int port() { return port_; }
QString username() { return username_; }
QString password() { return password_; }
bool verify_certificate() { return verify_certificate_; }
bool cache_album_covers() { return cache_album_covers_; }
CollectionBackend *collection_backend() { return collection_backend_; }
CollectionModel *collection_model() { return collection_model_; }
QSortFilterProxyModel *collection_sort_model() { return collection_sort_model_; }
CollectionBackend *songs_collection_backend() { return collection_backend_; }
CollectionModel *songs_collection_model() { return collection_model_; }
QSortFilterProxyModel *songs_collection_sort_model() { return collection_sort_model_; }
void CheckConfiguration();
signals:
public slots:
void ShowConfig();
void SendPing();
void SendPing(const QString &hostname, const int port, const QString &username, const QString &password);
void GetSongs();
void ResetSongsRequest();
private slots:
void HandlePingReply(QNetworkReply *reply);
void SongsResultsReceived(SongList songs);
void SongsErrorReceived(QString error);
private:
typedef QPair<QString, QString> Param;
typedef QList<Param> ParamList;
typedef QPair<QByteArray, QByteArray> EncodedParam;
typedef QList<EncodedParam> EncodedParamList;
QString PingError(QString error, QVariant debug = QVariant());
static const char *kClientName;
static const char *kApiVersion;
static const char *kSongsTable;
static const char *kSongsFtsTable;
Application *app_;
NetworkAccessManager *network_;
SubsonicUrlHandler *url_handler_;
CollectionBackend *collection_backend_;
CollectionModel *collection_model_;
QSortFilterProxyModel *collection_sort_model_;
std::shared_ptr<SubsonicRequest> songs_request_;
QString hostname_;
int port_;
QString username_;
QString password_;
bool verify_certificate_;
bool cache_album_covers_;
};
#endif // SUBSONICSERVICE_H

View File

@@ -0,0 +1,57 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 <QObject>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include "subsonicservice.h"
#include "subsonicurlhandler.h"
class Application;
SubsonicUrlHandler::SubsonicUrlHandler(Application *app, SubsonicService *service) : UrlHandler(service), service_(service) {}
UrlHandler::LoadResult SubsonicUrlHandler::StartLoading(const QUrl &url) {
ParamList params = ParamList() << Param("c", service_->client_name())
<< Param("v", service_->api_version())
<< Param("f", "json")
<< Param("u", service_->username())
<< Param("p", QString("enc:" + service_->password().toUtf8().toHex()))
<< Param("id", url.path());
QUrlQuery url_query;
for (const Param& param : params) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
url_query.addQueryItem(encoded_param.first, encoded_param.second);
}
QUrl media_url;
media_url.setScheme("https");
media_url.setHost(service_->hostname());
if (service_->port() > 0 && service_->port() != 443)
media_url.setPort(service_->port());
media_url.setPath("/rest/stream");
media_url.setQuery(url_query);
return LoadResult(url, LoadResult::TrackAvailable, media_url);
}

View File

@@ -0,0 +1,57 @@
/*
* Strawberry Music Player
* Copyright 2019, 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 SUBSONICURLHANDLER_H
#define SUBSONICURLHANDLER_H
#include <QObject>
#include <QPair>
#include <QList>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include "core/urlhandler.h"
#include "core/song.h"
#include "subsonic/subsonicservice.h"
class Application;
class SubsonicService;
class SubsonicUrlHandler : public UrlHandler {
Q_OBJECT
public:
SubsonicUrlHandler(Application *app, SubsonicService *service);
QString scheme() const { return service_->url_scheme(); }
LoadResult StartLoading(const QUrl &url);
private:
typedef QPair<QString, QString> Param;
typedef QList<Param> ParamList;
typedef QPair<QByteArray, QByteArray> EncodedParam;
typedef QList<EncodedParam> EncodedParamList;
SubsonicService *service_;
};
#endif // SUBSONICURLHANDLER_H

View File

@@ -33,13 +33,13 @@
#include <QUrl>
#include <QNetworkReply>
#include <QTimer>
#include <QSortFilterProxyModel>
#include "core/song.h"
#include "internet/internetservice.h"
#include "internet/internetsearch.h"
#include "settings/tidalsettingspage.h"
class QSortFilterProxyModel;
class Application;
class NetworkAccessManager;
class TidalUrlHandler;