diff --git a/CMakeLists.txt b/CMakeLists.txt index 07409145..b42d3f82 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -338,6 +338,12 @@ if(WIN32) add_definitions(-DSTATIC_GETOPT -D_UNICODE) endif() +if(BUILD_WITH_QT6) + pkg_check_modules(LIBMYGPO libmygpo-qt6) +else() + pkg_check_modules(LIBMYGPO libmygpo-qt5) +endif() + if(WIN32 AND NOT MSVC) # RC compiler string(REPLACE "gcc" "windres" CMAKE_RC_COMPILER_INIT ${CMAKE_C_COMPILER}) @@ -451,6 +457,8 @@ optional_component(EBUR128 ON "EBU R 128 loudness normalization" DEPENDS "gstreamer" HAVE_GSTREAMER ) +optional_component(PODCASTS ON "Podcasts support" DEPENDS "libmygpo" LIBMYGPO_FOUND) + if(APPLE OR WIN32) set(USE_BUNDLE_DEFAULT ON) else() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 629f1fd7..aa976b67 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -939,11 +939,75 @@ optional_source(HAVE_MOODBAR settings/moodbarsettingspage.ui ) + # EBU R 128 optional_source(HAVE_EBUR128 SOURCES engine/ebur128analysis.cpp ) +optional_source(HAVE_PODCASTS + SOURCES + podcasts/gpoddersync.cpp + podcasts/gpoddertoptagsmodel.cpp + podcasts/gpoddertoptagspage.cpp + podcasts/itunessearchpage.cpp + podcasts/podcastbackend.cpp + podcasts/podcastservice.cpp + podcasts/podcast.cpp + podcasts/podcastdownloader.cpp + podcasts/podcastupdater.cpp + podcasts/podcastdeleter.cpp + podcasts/podcastdiscoverymodel.cpp + podcasts/podcastepisode.cpp + podcasts/podcastinfodialog.cpp + podcasts/podcastinfowidget.cpp + podcasts/podcastparser.cpp + podcasts/podcastservicemodel.cpp + podcasts/podcasturlloader.cpp + podcasts/gpoddersearchpage.cpp + podcasts/addpodcastbyurl.cpp + podcasts/addpodcastdialog.cpp + podcasts/addpodcastpage.cpp + podcasts/episodeinfowidget.cpp + podcasts/fixedopmlpage.cpp + settings/podcastsettingspage.cpp + HEADERS + podcasts/addpodcastbyurl.h + podcasts/addpodcastdialog.h + podcasts/addpodcastpage.h + podcasts/episodeinfowidget.h + podcasts/fixedopmlpage.h + podcasts/gpoddersync.h + podcasts/gpoddertoptagsmodel.h + podcasts/gpoddertoptagspage.h + podcasts/itunessearchpage.h + podcasts/opmlcontainer.h + podcasts/podcastbackend.h + podcasts/podcastdeleter.h + podcasts/podcastdiscoverymodel.h + podcasts/podcastdownloader.h + podcasts/podcastepisode.h + podcasts/podcast.h + podcasts/podcastinfodialog.h + podcasts/podcastinfowidget.h + podcasts/podcastparser.h + podcasts/podcastservice.h + podcasts/podcastservicemodel.h + podcasts/podcastupdater.h + podcasts/podcasturlloader.h + podcasts/gpoddersearchpage.h + settings/podcastsettingspage.h + UI + podcasts/addpodcastbyurl.ui + podcasts/addpodcastdialog.ui + podcasts/episodeinfowidget.ui + podcasts/itunessearchpage.ui + podcasts/podcastinfodialog.ui + podcasts/podcastinfowidget.ui + podcasts/gpoddersearchpage.ui + settings/podcastsettingspage.ui +) + configure_file(${CMAKE_CURRENT_SOURCE_DIR}/config.h.in ${CMAKE_CURRENT_BINARY_DIR}/config.h) configure_file(${CMAKE_CURRENT_SOURCE_DIR}/version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h) @@ -1059,6 +1123,10 @@ if(USE_TAGPARSER AND TAGPARSER_FOUND) link_directories(${TAGPARSER_LIBRARY_DIRS}) endif() +if(HAVE_PODCASTS) + link_directories(${LIBMYGPO_LIBRARY_DIRS}) +endif() + if(HAVE_QTSPARKLE) link_directories(${QTSPARKLE_LIBRARY_DIRS}) endif() @@ -1214,6 +1282,11 @@ if(HAVE_LIBMTP) target_link_libraries(strawberry_lib PRIVATE ${LIBMTP_LIBRARIES}) endif() +if(HAVE_PODCASTS) + target_include_directories(strawberry_lib SYSTEM PRIVATE ${LIBMYGPO_INCLUDE_DIRS}) + target_link_libraries(strawberry_lib PRIVATE ${LIBMYGPO_LIBRARIES}) +endif(HAVE_PODCASTS) + if(APPLE) target_link_libraries(strawberry_lib PRIVATE "-framework AppKit" diff --git a/src/config.h.in b/src/config.h.in index 3be0c0f2..4cae4e7f 100644 --- a/src/config.h.in +++ b/src/config.h.in @@ -33,6 +33,7 @@ #cmakedefine HAVE_QOBUZ #cmakedefine HAVE_MOODBAR +#cmakedefine HAVE_PODCASTS #cmakedefine HAVE_KEYSYMDEF_H #cmakedefine HAVE_XF86KEYSYM_H diff --git a/src/core/application.cpp b/src/core/application.cpp index 22802e41..c23f9978 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -102,7 +102,16 @@ #include "radios/radioservices.h" #include "radios/radiobackend.h" +#ifdef HAVE_PODCASTS +# include "podcasts/podcastbackend.h" +# include "podcasts/gpoddersync.h" +# include "podcasts/podcastdownloader.h" +# include "podcasts/podcastupdater.h" +# include "podcasts/podcastdeleter.h" +#endif + using std::make_shared; + using namespace std::chrono_literals; class ApplicationImpl { @@ -200,6 +209,21 @@ class ApplicationImpl { #ifdef HAVE_MOODBAR moodbar_loader_([app]() { return new MoodbarLoader(app); }), moodbar_controller_([app]() { return new MoodbarController(app); }), +#endif +#ifdef HAVE_PODCASTS + podcast_backend_([app]() { + PodcastBackend* backend = new PodcastBackend(app, app); + app->MoveToThread(backend, database_->thread()); + return backend; + }), + gpodder_sync_([app]() { return new GPodderSync(app, app); }), + podcast_downloader_([app]() { return new PodcastDownloader(app, app); }), + podcast_updater_([app]() { return new PodcastUpdater(app, app); }), + podcast_deleter_([app]() { + PodcastDeleter* deleter = new PodcastDeleter(app, app); + app->MoveToNewThread(deleter); + return deleter; + }), #endif lastfm_import_([app]() { return new LastFMImport(app->network()); }) {} @@ -226,6 +250,13 @@ class ApplicationImpl { #ifdef HAVE_MOODBAR Lazy moodbar_loader_; Lazy moodbar_controller_; +#endif +#ifdef HAVE_PODCASTS + Lazy podcast_backend_; + Lazy gpodder_sync_; + Lazy podcast_downloader_; + Lazy podcast_updater_; + Lazy podcast_deleter_; #endif Lazy lastfm_import_; @@ -359,3 +390,10 @@ SharedPtr Application::lastfm_import() const { return p_->lastfm_i SharedPtr Application::moodbar_controller() const { return p_->moodbar_controller_.ptr(); } SharedPtr Application::moodbar_loader() const { return p_->moodbar_loader_.ptr(); } #endif +#ifdef HAVE_PODCASTS +PodcastBackend *Application::podcast_backend() const { return p_->podcast_backend_.get(); } +GPodderSync *Application::gpodder_sync() const { return p_->gpodder_sync_.get(); } +PodcastDownloader *Application::podcast_downloader() const { return p_->podcast_downloader_.get(); } +PodcastUpdater *Application::podcast_updater() const { return p_->podcast_updater_.get(); } +PodcastDeleter *Application::podcast_deleter() const { return p_->podcast_deleter_.get(); } +#endif diff --git a/src/core/application.h b/src/core/application.h index 3e31e8ca..1ae174ea 100644 --- a/src/core/application.h +++ b/src/core/application.h @@ -64,6 +64,14 @@ class RadioServices; class MoodbarController; class MoodbarLoader; #endif +#ifdef HAVE_PODCASTS +class PodcastBackend; +class GPodderSync; +class PodcastDownloader; +class PodcastUpdater; +class PodcastDeleter; +#endif + class Application : public QObject { Q_OBJECT @@ -104,6 +112,13 @@ class Application : public QObject { SharedPtr moodbar_controller() const; SharedPtr moodbar_loader() const; #endif +#ifdef HAVE_PODCASTS + PodcastBackend *podcast_backend() const; + GPodderSync *gpodder_sync() const; + PodcastDownloader *podcast_downloader() const; + PodcastUpdater *podcast_updater() const; + PodcastDeleter *podcast_deleter() const; +#endif SharedPtr lastfm_import() const; diff --git a/src/podcasts/addpodcastbyurl.cpp b/src/podcasts/addpodcastbyurl.cpp new file mode 100644 index 00000000..a9fd30c1 --- /dev/null +++ b/src/podcasts/addpodcastbyurl.cpp @@ -0,0 +1,116 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include + +#include "core/iconloader.h" +#include "podcastdiscoverymodel.h" +#include "podcasturlloader.h" +#include "addpodcastbyurl.h" +#include "ui_addpodcastbyurl.h" + +AddPodcastByUrl::AddPodcastByUrl(Application *app, QWidget *parent) + : AddPodcastPage(app, parent), + ui_(new Ui_AddPodcastByUrl), + loader_(new PodcastUrlLoader(this)) { + + ui_->setupUi(this); + QObject::connect(ui_->go, &QPushButton::clicked, this, &AddPodcastByUrl::GoClicked); + setWindowIcon(IconLoader::Load("podcast")); + +} + +AddPodcastByUrl::~AddPodcastByUrl() { delete ui_; } + +void AddPodcastByUrl::SetUrlAndGo(const QUrl &url) { + + ui_->url->setText(url.toString()); + GoClicked(); + +} + +void AddPodcastByUrl::SetOpml(const OpmlContainer &opml) { + + ui_->url->setText(opml.url.toString()); + model()->clear(); + model()->CreateOpmlContainerItems(opml, model()->invisibleRootItem()); + +} + +void AddPodcastByUrl::GoClicked() { + + emit Busy(true); + model()->clear(); + + PodcastUrlLoaderReply* reply = loader_->Load(ui_->url->text()); + ui_->url->setText(reply->url().toString()); + + QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply]() { RequestFinished(reply); }); + +} + +void AddPodcastByUrl::RequestFinished(PodcastUrlLoaderReply *reply) { + + reply->deleteLater(); + + emit Busy(false); + + if (!reply->is_success()) { + QMessageBox::warning(this, tr("Failed to load podcast"), reply->error_text(), QMessageBox::Close); + return; + } + + switch (reply->result_type()) { + case PodcastUrlLoaderReply::Type_Podcast: + for (const Podcast& podcast : reply->podcast_results()) { + model()->appendRow(model()->CreatePodcastItem(podcast)); + } + break; + + case PodcastUrlLoaderReply::Type_Opml: + model()->CreateOpmlContainerItems(reply->opml_results(), model()->invisibleRootItem()); + break; + } + +} + +void AddPodcastByUrl::Show() { + + ui_->url->setFocus(); + + const QClipboard *clipboard = QApplication::clipboard(); + QStringList contents; + contents << clipboard->text(QClipboard::Selection) << clipboard->text(QClipboard::Clipboard); + + for (const QString &content : contents) { + if (content.contains("://")) { + ui_->url->setText(content); + return; + } + } + +} diff --git a/src/podcasts/addpodcastbyurl.h b/src/podcasts/addpodcastbyurl.h new file mode 100644 index 00000000..58643764 --- /dev/null +++ b/src/podcasts/addpodcastbyurl.h @@ -0,0 +1,60 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef ADDPODCASTBYURL_H +#define ADDPODCASTBYURL_H + +#include +#include + +#include "addpodcastpage.h" +#include "opmlcontainer.h" + +class Application; +class AddPodcastPage; +class PodcastUrlLoader; +class PodcastUrlLoaderReply; +class Ui_AddPodcastByUrl; + +class AddPodcastByUrl : public AddPodcastPage { + Q_OBJECT + + public: + explicit AddPodcastByUrl(Application *app, QWidget *parent = nullptr); + ~AddPodcastByUrl(); + + void Show(); + + void SetOpml(const OpmlContainer &opml); + void SetUrlAndGo(const QUrl &url); + + private slots: + void GoClicked(); + void RequestFinished(PodcastUrlLoaderReply *reply); + + private: + Ui_AddPodcastByUrl *ui_; + PodcastUrlLoader *loader_; +}; + +#endif // ADDPODCASTBYURL_H diff --git a/src/podcasts/addpodcastbyurl.ui b/src/podcasts/addpodcastbyurl.ui new file mode 100644 index 00000000..0b612024 --- /dev/null +++ b/src/podcasts/addpodcastbyurl.ui @@ -0,0 +1,61 @@ + + + AddPodcastByUrl + + + + 0 + 0 + 431 + 51 + + + + Enter a URL + + + + 0 + + + + + If you know the URL of a podcast, enter it below and press Go. + + + + + + + + + + + + Go + + + + + + + + + + url + returnPressed() + go + click() + + + 109 + 24 + + + 429 + 49 + + + + + diff --git a/src/podcasts/addpodcastdialog.cpp b/src/podcasts/addpodcastdialog.cpp new file mode 100644 index 00000000..f793f42a --- /dev/null +++ b/src/podcasts/addpodcastdialog.cpp @@ -0,0 +1,270 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/iconloader.h" +#include "widgets/widgetfadehelper.h" +#include "fixedopmlpage.h" +#include "gpoddersearchpage.h" +#include "gpoddertoptagspage.h" +#include "itunessearchpage.h" +#include "podcastbackend.h" +#include "podcastdiscoverymodel.h" +#include "addpodcastbyurl.h" +#include "podcastinfowidget.h" +#include "addpodcastdialog.h" +#include "ui_addpodcastdialog.h" + +const char *AddPodcastDialog::kBbcOpmlUrl = "http://www.bbc.co.uk/podcasts.opml"; +const char *AddPodcastDialog::kCbcOpmlUrl = "http://cbc.ca/podcasts.opml"; + +AddPodcastDialog::AddPodcastDialog(Application *app, QWidget *parent) + : QDialog(parent), + app_(app), + ui_(new Ui_AddPodcastDialog), + last_opml_path_(QDir::homePath()) { + + ui_->setupUi(this); + ui_->details->SetApplication(app); + ui_->results->SetExpandOnReset(false); + ui_->results->SetAddOnDoubleClick(false); + ui_->results_stack->setCurrentWidget(ui_->results_page); + + fader_ = new WidgetFadeHelper(ui_->details_scroll_area); + + QObject::connect(ui_->provider_list, &QListWidget::currentRowChanged, this, &AddPodcastDialog::ChangePage); + QObject::connect(ui_->details, &PodcastInfoWidget::LoadingFinished, fader_, &WidgetFadeHelper::StartFade); + QObject::connect(ui_->results, &AutoExpandingTreeView::doubleClicked, this, &AddPodcastDialog::PodcastDoubleClicked); + + // Create Add and Remove Podcast buttons + add_button_ = new QPushButton(IconLoader::Load("list-add"), tr("Add Podcast"), this); + add_button_->setEnabled(false); + connect(add_button_, &QPushButton::clicked, this, &AddPodcastDialog::AddPodcast); + ui_->button_box->addButton(add_button_, QDialogButtonBox::ActionRole); + + remove_button_ = new QPushButton(IconLoader::Load("list-remove"), tr("Unsubscribe"), this); + remove_button_->setEnabled(false); + connect(remove_button_, &QPushButton::clicked, this, &AddPodcastDialog::RemovePodcast); + ui_->button_box->addButton(remove_button_, QDialogButtonBox::ActionRole); + + QPushButton *settings_button = new QPushButton(IconLoader::Load("configure"), tr("Configure podcasts..."), this); + connect(settings_button, &QPushButton::clicked, this, &AddPodcastDialog::OpenSettingsPage); + ui_->button_box->addButton(settings_button, QDialogButtonBox::ResetRole); + + // Create an Open OPML file button + QPushButton *open_opml_button = new QPushButton(IconLoader::Load("document-open"), tr("Open OPML file..."), this); + QObject::connect(open_opml_button, &QPushButton::clicked, this, &AddPodcastDialog::OpenOPMLFile); + ui_->button_box->addButton(open_opml_button, QDialogButtonBox::ResetRole); + + // Add providers + by_url_page_ = new AddPodcastByUrl(app, this); + AddPage(by_url_page_); + AddPage(new FixedOpmlPage(QUrl(kBbcOpmlUrl), tr("BBC Podcasts"), IconLoader::Load("bbc"), app, this)); + AddPage(new FixedOpmlPage(QUrl(kCbcOpmlUrl), tr("CBC Podcasts"), IconLoader::Load("cbc"), app, this)); + AddPage(new GPodderTopTagsPage(app, this)); + AddPage(new GPodderSearchPage(app, this)); + AddPage(new ITunesSearchPage(app, this)); + + ui_->provider_list->setCurrentRow(0); +} + +AddPodcastDialog::~AddPodcastDialog() { delete ui_; } + +void AddPodcastDialog::ShowWithUrl(const QUrl& url) { + + by_url_page_->SetUrlAndGo(url); + ui_->provider_list->setCurrentRow(0); + show(); + +} + +void AddPodcastDialog::ShowWithOpml(const OpmlContainer& opml) { + + by_url_page_->SetOpml(opml); + ui_->provider_list->setCurrentRow(0); + show(); + +} + +void AddPodcastDialog::AddPage(AddPodcastPage *page) { + + pages_.append(page); + page_is_busy_.append(false); + + ui_->stack->addWidget(page); + new QListWidgetItem(page->windowIcon(), page->windowTitle(), ui_->provider_list); + + QObject::connect(page, &AddPodcastPage::Busy, this, &AddPodcastDialog::PageBusyChanged); + +} + +void AddPodcastDialog::ChangePage(const int index) { + + AddPodcastPage *page = pages_[index]; + + ui_->stack->setCurrentIndex(index); + ui_->stack->setVisible(page->has_visible_widget()); + ui_->results->setModel(page->model()); + + ui_->results_stack->setCurrentWidget(page_is_busy_[index] ? ui_->busy_page : ui_->results_page); + + QObject::connect(ui_->results->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &AddPodcastDialog::ChangePodcast); + ChangePodcast(QModelIndex()); + CurrentPageBusyChanged(page_is_busy_[index]); + + page->Show(); + +} + +void AddPodcastDialog::ChangePodcast(const QModelIndex ¤t) { + + QVariant podcast_variant = current.data(PodcastDiscoveryModel::Role_Podcast); + + // If the selected item is invalid or not a podcast, hide the details pane. + if (podcast_variant.isNull()) { + ui_->details_scroll_area->hide(); + add_button_->setEnabled(false); + remove_button_->setEnabled(false); + return; + } + + current_podcast_ = podcast_variant.value(); + + // Start the blur+fade if there's already a podcast in the details pane. + if (ui_->details_scroll_area->isVisible()) { + fader_->StartBlur(); + } + else { + ui_->details_scroll_area->show(); + } + + // Update the details pane + ui_->details->SetPodcast(current_podcast_); + + // Is the user already subscribed to this podcast? + Podcast subscribed_podcast = app_->podcast_backend()->GetSubscriptionByUrl(current_podcast_.url()); + const bool is_subscribed = subscribed_podcast.url().isValid(); + + if (is_subscribed) { + // Use the one from the database which will contain the ID. + current_podcast_ = subscribed_podcast; + } + + add_button_->setEnabled(!is_subscribed); + remove_button_->setEnabled(is_subscribed); + +} + +void AddPodcastDialog::PageBusyChanged(const bool busy) { + + const int index = pages_.indexOf(qobject_cast(sender())); + if (index == -1) return; + + page_is_busy_[index] = busy; + + if (index == ui_->provider_list->currentRow()) { + CurrentPageBusyChanged(busy); + } + +} + +void AddPodcastDialog::CurrentPageBusyChanged(const bool busy) { + + ui_->results_stack->setCurrentWidget(busy ? ui_->busy_page : ui_->results_page); + ui_->stack->setDisabled(busy); + + QTimer::singleShot(0, this, &AddPodcastDialog::SelectFirstPodcast); + +} + +void AddPodcastDialog::SelectFirstPodcast() { + + // Select the first item if there was one. + const PodcastDiscoveryModel *model = pages_[ui_->provider_list->currentRow()]->model(); + if (model->rowCount() > 0) { + ui_->results->selectionModel()->setCurrentIndex(model->index(0, 0), QItemSelectionModel::ClearAndSelect); + } + +} + +void AddPodcastDialog::AddPodcast() { + + app_->podcast_backend()->Subscribe(¤t_podcast_); + add_button_->setEnabled(false); + remove_button_->setEnabled(true); + +} + +void AddPodcastDialog::PodcastDoubleClicked(const QModelIndex &idx) { + + QVariant podcast_variant = idx.data(PodcastDiscoveryModel::Role_Podcast); + if (podcast_variant.isNull()) { + return; + } + + current_podcast_ = podcast_variant.value(); + app_->podcast_backend()->Subscribe(¤t_podcast_); + + add_button_->setEnabled(false); + remove_button_->setEnabled(true); + +} + +void AddPodcastDialog::RemovePodcast() { + + app_->podcast_backend()->Unsubscribe(current_podcast_); + current_podcast_.set_database_id(-1); + add_button_->setEnabled(true); + remove_button_->setEnabled(false); + +} + +void AddPodcastDialog::OpenSettingsPage() { + //app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Podcasts); +} + +void AddPodcastDialog::OpenOPMLFile() { + + const QString filename = QFileDialog::getOpenFileName(this, tr("Open OPML file"), last_opml_path_, "OPML files (*.opml)"); + + if (filename.isEmpty()) { + return; + } + + last_opml_path_ = filename; + + by_url_page_->SetUrlAndGo(QUrl::fromLocalFile(last_opml_path_)); + ChangePage(ui_->stack->indexOf(by_url_page_)); + +} diff --git a/src/podcasts/addpodcastdialog.h b/src/podcasts/addpodcastdialog.h new file mode 100644 index 00000000..af734b26 --- /dev/null +++ b/src/podcasts/addpodcastdialog.h @@ -0,0 +1,91 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef ADDPODCASTDIALOG_H +#define ADDPODCASTDIALOG_H + +#include +#include +#include +#include + +#include "podcast.h" + +class Application; +class AddPodcastByUrl; +class AddPodcastPage; +class OpmlContainer; +class WidgetFadeHelper; +class Ui_AddPodcastDialog; + +class AddPodcastDialog : public QDialog { + Q_OBJECT + + public: + explicit AddPodcastDialog(Application *app, QWidget *parent = nullptr); + ~AddPodcastDialog(); + + // Convenience methods that open the dialog at the Add By Url page and fill it with either a URL (which is then fetched), or a pre-fetched OPML container. + void ShowWithUrl(const QUrl &url); + void ShowWithOpml(const OpmlContainer &opml); + + private slots: + void OpenSettingsPage(); + void AddPodcast(); + void PodcastDoubleClicked(const QModelIndex &idx); + void RemovePodcast(); + void ChangePage(const int index); + void ChangePodcast(const QModelIndex ¤t); + + void PageBusyChanged(const bool busy); + void CurrentPageBusyChanged(const bool busy); + + void SelectFirstPodcast(); + + void OpenOPMLFile(); + + private: + void AddPage(AddPodcastPage *page); + + private: + static const char *kBbcOpmlUrl; + static const char *kCbcOpmlUrl; + + Application *app_; + + Ui_AddPodcastDialog *ui_; + QPushButton *add_button_; + QPushButton *remove_button_; + + QList pages_; + QList page_is_busy_; + AddPodcastByUrl *by_url_page_; + + WidgetFadeHelper *fader_; + + Podcast current_podcast_; + + QString last_opml_path_; +}; + +#endif // ADDPODCASTDIALOG_H diff --git a/src/podcasts/addpodcastdialog.ui b/src/podcasts/addpodcastdialog.ui new file mode 100644 index 00000000..caa1758e --- /dev/null +++ b/src/podcasts/addpodcastdialog.ui @@ -0,0 +1,276 @@ + + + AddPodcastDialog + + + + 0 + 0 + 941 + 473 + + + + Add podcast + + + + + + + + + 200 + 16777215 + + + + Qt::ScrollBarAlwaysOff + + + + 32 + 32 + + + + true + + + + + + + + + + 0 + 0 + + + + + + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QAbstractItemView::NoEditTriggers + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Vertical + + + + 0 + 192 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Loading... + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + + + + + + + 250 + 0 + + + + + 250 + 16777215 + + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 248 + 415 + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + BusyIndicator + QWidget +
widgets/busyindicator.h
+ 1 +
+ + PodcastInfoWidget + QWidget +
podcasts/podcastinfowidget.h
+ 1 +
+ + AutoExpandingTreeView + QTreeView +
widgets/autoexpandingtreeview.h
+
+
+ + + + button_box + accepted() + AddPodcastDialog + accept() + + + 836 + 463 + + + 157 + 274 + + + + + button_box + rejected() + AddPodcastDialog + reject() + + + 885 + 463 + + + 286 + 274 + + + + +
diff --git a/src/podcasts/addpodcastpage.cpp b/src/podcasts/addpodcastpage.cpp new file mode 100644 index 00000000..b949af34 --- /dev/null +++ b/src/podcasts/addpodcastpage.cpp @@ -0,0 +1,36 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include + +#include "addpodcastpage.h" + +#include "podcastdiscoverymodel.h" + +AddPodcastPage::AddPodcastPage(Application *app, QWidget *parent) + : QWidget(parent), model_(new PodcastDiscoveryModel(app, this)) {} + +void AddPodcastPage::SetModel(PodcastDiscoveryModel *model) { + delete model_; + model_ = model; +} diff --git a/src/podcasts/addpodcastpage.h b/src/podcasts/addpodcastpage.h new file mode 100644 index 00000000..ba09de86 --- /dev/null +++ b/src/podcasts/addpodcastpage.h @@ -0,0 +1,53 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef ADDPODCASTPAGE_H +#define ADDPODCASTPAGE_H + +#include + +class Application; +class PodcastDiscoveryModel; + +class AddPodcastPage : public QWidget { + Q_OBJECT + + public: + explicit AddPodcastPage(Application *app, QWidget *parent = nullptr); + + PodcastDiscoveryModel *model() const { return model_; } + + virtual bool has_visible_widget() const { return true; } + virtual void Show() {} + + signals: + void Busy(bool busy); + + protected: + void SetModel(PodcastDiscoveryModel *model); + + private: + PodcastDiscoveryModel *model_; +}; + +#endif // ADDPODCASTPAGE_H diff --git a/src/podcasts/episodeinfowidget.cpp b/src/podcasts/episodeinfowidget.cpp new file mode 100644 index 00000000..00c8c431 --- /dev/null +++ b/src/podcasts/episodeinfowidget.cpp @@ -0,0 +1,49 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + Copyright 2018, Jim Broadus + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include + +#include "core/utilities.h" + +#include "episodeinfowidget.h" +#include "ui_episodeinfowidget.h" + +EpisodeInfoWidget::EpisodeInfoWidget(QWidget *parent) + : QWidget(parent), ui_(new Ui_EpisodeInfoWidget), app_(nullptr) { + + ui_->setupUi(this); + +} + +EpisodeInfoWidget::~EpisodeInfoWidget() { delete ui_; } + +void EpisodeInfoWidget::SetApplication(Application *app) { app_ = app; } + +void EpisodeInfoWidget::SetEpisode(const PodcastEpisode &episode) { + + episode_ = episode; + ui_->title->setText(episode.title()); + ui_->description->setText(episode.description()); + ui_->author->setText(episode.author()); + ui_->date->setText(episode.publication_date().toString("d MMMM yyyy")); + ui_->duration->setText(Utilities::PrettyTime(episode.duration_secs())); + +} diff --git a/src/podcasts/episodeinfowidget.h b/src/podcasts/episodeinfowidget.h new file mode 100644 index 00000000..d3bad760 --- /dev/null +++ b/src/podcasts/episodeinfowidget.h @@ -0,0 +1,50 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + Copyright 2018, Jim Broadus + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef EPISODEINFOWIDGET_H +#define EPISODEINFOWIDGET_H + +#include + +#include "podcastepisode.h" + +class Application; +class Ui_EpisodeInfoWidget; + +class EpisodeInfoWidget : public QWidget { + Q_OBJECT + + public: + explicit EpisodeInfoWidget(QWidget *parent = nullptr); + ~EpisodeInfoWidget(); + + void SetApplication(Application *app); + + void SetEpisode(const PodcastEpisode &episode); + + private: + Ui_EpisodeInfoWidget *ui_; + + Application *app_; + PodcastEpisode episode_; +}; + +#endif // EPISODEINFOWIDGET_H diff --git a/src/podcasts/episodeinfowidget.ui b/src/podcasts/episodeinfowidget.ui new file mode 100644 index 00000000..e4945a45 --- /dev/null +++ b/src/podcasts/episodeinfowidget.ui @@ -0,0 +1,137 @@ + + + EpisodeInfoWidget + + + + 0 + 0 + 398 + 551 + + + + Form + + + #title { + font-weight: bold; +} + +#description { + font-size: smaller; +} + +QLineEdit { + background: transparent; +} + + + + QLayout::SetMinAndMaxSize + + + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + QLayout::SetMinAndMaxSize + + + + + Author + + + true + + + + + + + false + + + true + + + + + + + false + + + true + + + + + + + Date + + + true + + + + + + + Duration + + + true + + + + + + + false + + + true + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + diff --git a/src/podcasts/fixedopmlpage.cpp b/src/podcasts/fixedopmlpage.cpp new file mode 100644 index 00000000..d08915e8 --- /dev/null +++ b/src/podcasts/fixedopmlpage.cpp @@ -0,0 +1,82 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include + +#include "podcastdiscoverymodel.h" +#include "podcasturlloader.h" + +#include "fixedopmlpage.h" + +FixedOpmlPage::FixedOpmlPage(const QUrl &opml_url, const QString &title, const QIcon &icon, Application *app, QWidget *parent) + : AddPodcastPage(app, parent), + loader_(new PodcastUrlLoader(this)), + opml_url_(opml_url), + done_initial_load_(false) { + + setWindowTitle(title); + setWindowIcon(icon); + +} + +void FixedOpmlPage::Show() { + + if (!done_initial_load_) { + emit Busy(true); + done_initial_load_ = true; + + PodcastUrlLoaderReply *reply = loader_->Load(opml_url_); + QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply]() { LoadFinished(reply); }); + } + +} + +void FixedOpmlPage::LoadFinished(PodcastUrlLoaderReply *reply) { + + reply->deleteLater(); + emit Busy(false); + + if (!reply->is_success()) { + QMessageBox::warning(this, tr("Failed to load podcast"), reply->error_text(), QMessageBox::Close); + return; + } + + switch (reply->result_type()) { + case PodcastUrlLoaderReply::Type_Podcast:{ + const PodcastList podcasts = reply->podcast_results(); + for (const Podcast &podcast : podcasts) { + model()->appendRow(model()->CreatePodcastItem(podcast)); + } + break; + } + + case PodcastUrlLoaderReply::Type_Opml: + model()->CreateOpmlContainerItems(reply->opml_results(), model()->invisibleRootItem()); + break; + } + +} diff --git a/src/podcasts/fixedopmlpage.h b/src/podcasts/fixedopmlpage.h new file mode 100644 index 00000000..d1826d11 --- /dev/null +++ b/src/podcasts/fixedopmlpage.h @@ -0,0 +1,56 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef FIXEDOPMLPAGE_H +#define FIXEDOPMLPAGE_H + +#include +#include +#include + +#include "addpodcastpage.h" + +class Application; +class PodcastUrlLoader; +class PodcastUrlLoaderReply; + +class FixedOpmlPage : public AddPodcastPage { + Q_OBJECT + + public: + FixedOpmlPage(const QUrl &opml_url, const QString &title, const QIcon &icon, Application *app, QWidget *parent = nullptr); + + bool has_visible_widget() const { return false; } + void Show(); + + private slots: + void LoadFinished(PodcastUrlLoaderReply *reply); + + private: + PodcastUrlLoader *loader_; + QUrl opml_url_; + + bool done_initial_load_; +}; + +#endif // FIXEDOPMLPAGE_H diff --git a/src/podcasts/gpoddersearchpage.cpp b/src/podcasts/gpoddersearchpage.cpp new file mode 100644 index 00000000..e65a42e7 --- /dev/null +++ b/src/podcasts/gpoddersearchpage.cpp @@ -0,0 +1,100 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include + +#include "core/application.h" +#include "core/iconloader.h" +#include "core/networkaccessmanager.h" +#include "podcast.h" +#include "podcastdiscoverymodel.h" + +#include "gpoddersearchpage.h" +#include "ui_gpoddersearchpage.h" + +GPodderSearchPage::GPodderSearchPage(Application *app, QWidget *parent) + : AddPodcastPage(app, parent), + ui_(new Ui_GPodderSearchPage), + network_(new NetworkAccessManager(this)), + api_(new mygpo::ApiRequest(network_)) { + + ui_->setupUi(this); + QObject::connect(ui_->search, &QPushButton::clicked, this, &GPodderSearchPage::SearchClicked); + setWindowIcon(IconLoader::Load("mygpo")); + +} + +GPodderSearchPage::~GPodderSearchPage() { + + delete ui_; + delete api_; + +} + +void GPodderSearchPage::SearchClicked() { + + emit Busy(true); + + mygpo::PodcastListPtr list(api_->search(ui_->query->text())); + QObject::connect(list.data(), &mygpo::PodcastList::finished, this, [this, list]() { SearchFinished(list); }); + QObject::connect(list.data(), &mygpo::PodcastList::parseError, this, [this, list]() { SearchFailed(list); }); + QObject::connect(list.data(), &mygpo::PodcastList::requestError, this, [this, list]() { SearchFailed(list); }); + +} + +void GPodderSearchPage::SearchFinished(mygpo::PodcastListPtr list) { + + emit Busy(false); + + model()->clear(); + + for (mygpo::PodcastPtr gpo_podcast : list->list()) { + Podcast podcast; + podcast.InitFromGpo(gpo_podcast.data()); + + model()->appendRow(model()->CreatePodcastItem(podcast)); + } + +} + +void GPodderSearchPage::SearchFailed(mygpo::PodcastListPtr list) { + + emit Busy(false); + + model()->clear(); + + if (QMessageBox::warning( + nullptr, tr("Failed to fetch podcasts"), + tr("There was a problem communicating with gpodder.net"), + QMessageBox::Retry | QMessageBox::Close, + QMessageBox::Retry) != QMessageBox::Retry) { + return; + } + + // Try doing the search again. + SearchClicked(); + +} + +void GPodderSearchPage::Show() { ui_->query->setFocus(); } diff --git a/src/podcasts/gpoddersearchpage.h b/src/podcasts/gpoddersearchpage.h new file mode 100644 index 00000000..b131ec0f --- /dev/null +++ b/src/podcasts/gpoddersearchpage.h @@ -0,0 +1,57 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef GPODDERSEARCHPAGE_H +#define GPODDERSEARCHPAGE_H + +#include + +#include "addpodcastpage.h" + +class QNetworkAccessManager; + +class Application; +class Ui_GPodderSearchPage; + +class GPodderSearchPage : public AddPodcastPage { + Q_OBJECT + + public: + explicit GPodderSearchPage(Application *app, QWidget *parent = nullptr); + ~GPodderSearchPage(); + + void Show(); + + private slots: + void SearchClicked(); + void SearchFinished(mygpo::PodcastListPtr list); + void SearchFailed(mygpo::PodcastListPtr list); + + private: + Ui_GPodderSearchPage *ui_; + + QNetworkAccessManager *network_; + mygpo::ApiRequest *api_; +}; + +#endif // GPODDERSEARCHPAGE_H diff --git a/src/podcasts/gpoddersearchpage.ui b/src/podcasts/gpoddersearchpage.ui new file mode 100644 index 00000000..c9545989 --- /dev/null +++ b/src/podcasts/gpoddersearchpage.ui @@ -0,0 +1,61 @@ + + + GPodderSearchPage + + + + 0 + 0 + 538 + 69 + + + + Search gpodder.net + + + + 0 + + + + + Enter search terms below to find podcasts on gpodder.net + + + + + + + + + + + + Search + + + + + + + + + + query + returnPressed() + search + click() + + + 130 + 45 + + + 198 + 46 + + + + + diff --git a/src/podcasts/gpoddersync.cpp b/src/podcasts/gpoddersync.cpp new file mode 100644 index 00000000..c1e720cc --- /dev/null +++ b/src/podcasts/gpoddersync.cpp @@ -0,0 +1,415 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/timeconstants.h" +#include "core/utilities.h" +#include "podcastbackend.h" +#include "podcasturlloader.h" +#include "gpoddersync.h" + +const char *GPodderSync::kSettingsGroup = "Podcasts"; +const int GPodderSync::kFlushUpdateQueueDelay = 30 * kMsecPerSec; // 30 seconds +const int GPodderSync::kGetUpdatesInterval = 30 * 60 * kMsecPerSec; // 30 minutes +const int GPodderSync::kRequestTimeout = 30 * kMsecPerSec; // 30 seconds + +GPodderSync::GPodderSync(Application *app, QObject *parent) + : QObject(parent), + app_(app), + network_(new NetworkAccessManager(this)), + backend_(app_->podcast_backend()), + loader_(new PodcastUrlLoader(this)), + get_updates_timer_(new QTimer(this)), + flush_queue_timer_(new QTimer(this)), + flushing_queue_(false) { + + ReloadSettings(); + LoadQueue(); + + QObject::connect(app_, &Application::SettingsChanged, this, &GPodderSync::ReloadSettings); + QObject::connect(backend_, &PodcastBackend::SubscriptionAdded, this, &GPodderSync::SubscriptionAdded); + QObject::connect(backend_, &PodcastBackend::SubscriptionRemoved, this, &GPodderSync::SubscriptionRemoved); + + get_updates_timer_->setInterval(kGetUpdatesInterval); + connect(get_updates_timer_, &QTimer::timeout, this, &GPodderSync::GetUpdatesNow); + + flush_queue_timer_->setInterval(kFlushUpdateQueueDelay); + flush_queue_timer_->setSingleShot(true); + QObject::connect(flush_queue_timer_, &QTimer::timeout, this, &GPodderSync::FlushUpdateQueue); + + if (is_logged_in()) { + GetUpdatesNow(); + flush_queue_timer_->start(); + get_updates_timer_->start(); + } +} + +GPodderSync::~GPodderSync() {} + +QString GPodderSync::DeviceId() { + + return QString("%1-%2").arg(qApp->applicationName(), QHostInfo::localHostName()).toLower(); + +} + +QString GPodderSync::DefaultDeviceName() { + return tr("%1 on %2").arg(qApp->applicationName(), QHostInfo::localHostName()); +} + +bool GPodderSync::is_logged_in() const { + return !username_.isEmpty() && !password_.isEmpty() && api_; +} + +void GPodderSync::ReloadSettings() { + + QSettings s; + s.beginGroup(kSettingsGroup); + + username_ = s.value("gpodder_username").toString(); + password_ = s.value("gpodder_password").toString(); + last_successful_get_ = s.value("gpodder_last_get").toDateTime(); + + s.endGroup(); + + if (!username_.isEmpty() && !password_.isEmpty()) { + api_.reset(new mygpo::ApiRequest(username_, password_, network_)); + } + +} + +void GPodderSync::Login(const QString &username, const QString &password, const QString &device_name) { + + api_.reset(new mygpo::ApiRequest(username, password, network_)); + + QNetworkReply *reply = api_->renameDevice(username, DeviceId(), device_name, mygpo::Device::DESKTOP); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, username, password]() { LoginFinished(reply, username, password); }); + +} + +void GPodderSync::LoginFinished(QNetworkReply *reply, const QString &username, const QString &password) { + + reply->deleteLater(); + + if (reply->error() == QNetworkReply::NoError) { + username_ = username; + password_ = password; + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("gpodder_username", username); + s.setValue("gpodder_password", password); + s.endGroup(); + + DoInitialSync(); + emit LoginSuccess(); + } + else { + api_.reset(); + emit LoginFailure(reply->errorString()); + } + +} + +void GPodderSync::Logout() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.remove("gpodder_username"); + s.remove("gpodder_password"); + s.remove("gpodder_last_get"); + s.endGroup(); + + api_.reset(); + + // Remove session cookies. QNetworkAccessManager takes ownership of the new object and frees the previous. + network_->setCookieJar(new QNetworkCookieJar()); + +} + +void GPodderSync::GetUpdatesNow() { + + if (!is_logged_in()) return; + + qlonglong timestamp = 0; + if (last_successful_get_.isValid()) { + timestamp = last_successful_get_.toSecsSinceEpoch(); + } + + mygpo::DeviceUpdatesPtr reply(api_->deviceUpdates(username_, DeviceId(), timestamp)); + QObject::connect(reply.data(), &mygpo::DeviceUpdates::finished, this, [this, reply]() { DeviceUpdatesFinished(reply); }); + QObject::connect(reply.data(), &mygpo::DeviceUpdates::parseError, this, &GPodderSync::DeviceUpdatesParseError); + QObject::connect(reply.data(), &mygpo::DeviceUpdates::requestError, this, &GPodderSync::DeviceUpdatesRequestError); + +} + +void GPodderSync::DeviceUpdatesParseError() { + qLog(Warning) << "Failed to get gpodder device updates: parse error"; +} + +void GPodderSync::DeviceUpdatesRequestError(QNetworkReply::NetworkError error) { + qLog(Warning) << "Failed to get gpodder device updates:" << error; +} + +void GPodderSync::DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr reply) { + + // Remember episode actions for each podcast, so when we add a new podcast + // we can apply the actions immediately. + QMap> episodes_by_podcast; + for (mygpo::EpisodePtr episode : reply->updateList()) { + episodes_by_podcast[episode->podcastUrl()].append(episode); + } + + for (mygpo::PodcastPtr podcast : reply->addList()) { + const QUrl url(podcast->url()); + + // Are we subscribed to this podcast already? + Podcast existing_podcast = backend_->GetSubscriptionByUrl(url); + if (existing_podcast.is_valid()) { + // Just apply actions to this existing podcast + ApplyActions(episodes_by_podcast[url], existing_podcast.mutable_episodes()); + backend_->UpdateEpisodes(existing_podcast.episodes()); + continue; + } + + // Start loading the podcast. Remember actions and apply them after we have a list of the episodes. + PodcastUrlLoaderReply *loader_reply = loader_->Load(url); + QObject::connect(loader_reply, &PodcastUrlLoaderReply::Finished, this, [this, loader_reply, url, episodes_by_podcast]() { NewPodcastLoaded(loader_reply, url, episodes_by_podcast[url]); }); + } + + // Unsubscribe from podcasts that were removed. + for (const QUrl &url : reply->removeList()) { + backend_->Unsubscribe(backend_->GetSubscriptionByUrl(url)); + } + + last_successful_get_ = QDateTime::currentDateTime(); + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("gpodder_last_get", last_successful_get_); + s.endGroup(); + +} + +void GPodderSync::NewPodcastLoaded(PodcastUrlLoaderReply *reply, const QUrl &url, const QList &actions) { + + reply->deleteLater(); + + if (!reply->is_success()) { + qLog(Warning) << "Error fetching podcast at" << url << ":" << reply->error_text(); + return; + } + + if (reply->result_type() != PodcastUrlLoaderReply::Type_Podcast) { + qLog(Warning) << "The URL" << url << "no longer contains a podcast"; + return; + } + + // Apply the actions to the episodes in the podcast. + for (Podcast podcast : reply->podcast_results()) { + ApplyActions(actions, podcast.mutable_episodes()); + + // Add the subscription + backend_->Subscribe(&podcast); + } +} + +void GPodderSync::ApplyActions(const QList> &actions, PodcastEpisodeList *episodes) { + + for (PodcastEpisodeList::iterator it = episodes->begin(); it != episodes->end(); ++it) { + // Find an action for this episode + for (mygpo::EpisodePtr action : actions) { + if (action->url() != it->url()) continue; + + switch (action->status()) { + case mygpo::Episode::PLAY: + case mygpo::Episode::DOWNLOAD: + it->set_listened(true); + break; + + default: + break; + } + break; + } + } + +} + +void GPodderSync::SubscriptionAdded(const Podcast &podcast) { + + if (!is_logged_in()) return; + + const QUrl &url = podcast.url(); + + queued_remove_subscriptions_.remove(url); + queued_add_subscriptions_.insert(url); + + SaveQueue(); + flush_queue_timer_->start(); + +} + +void GPodderSync::SubscriptionRemoved(const Podcast &podcast) { + + if (!is_logged_in()) return; + + const QUrl &url = podcast.url(); + + queued_remove_subscriptions_.insert(url); + queued_add_subscriptions_.remove(url); + + SaveQueue(); + flush_queue_timer_->start(); + +} + +namespace { +template +void WriteContainer(const T &container, QSettings *s, const char *array_name, const char *item_name) { + + s->beginWriteArray(array_name, container.count()); + int index = 0; + for (const auto &item : container) { + s->setArrayIndex(index++); + s->setValue(item_name, item); + } + s->endArray(); + +} + +template +void ReadContainer(T *container, QSettings *s, const char *array_name, const char *item_name) { + + container->clear(); + const int count = s->beginReadArray(array_name); + for (int i = 0; i < count; ++i) { + s->setArrayIndex(i); + *container << s->value(item_name).value(); + } + s->endArray(); + +} +} // namespace + +void GPodderSync::SaveQueue() { + + QSettings s; + s.beginGroup(kSettingsGroup); + WriteContainer(queued_add_subscriptions_, &s, "gpodder_queued_add_subscriptions", "url"); + WriteContainer(queued_remove_subscriptions_, &s, "gpodder_queued_remove_subscriptions", "url"); + s.endGroup(); + +} + +void GPodderSync::LoadQueue() { + + QSettings s; + s.beginGroup(kSettingsGroup); + ReadContainer(&queued_add_subscriptions_, &s, "gpodder_queued_add_subscriptions", "url"); + ReadContainer(&queued_remove_subscriptions_, &s, "gpodder_queued_remove_subscriptions", "url"); + s.endGroup(); + +} + +void GPodderSync::FlushUpdateQueue() { + + if (!is_logged_in() || flushing_queue_) return; + + QSet all_urls = queued_add_subscriptions_ + queued_remove_subscriptions_; + if (all_urls.isEmpty()) return; + + flushing_queue_ = true; + mygpo::AddRemoveResultPtr reply(api_->addRemoveSubscriptions(username_, DeviceId(), queued_add_subscriptions_.values(), queued_remove_subscriptions_.values())); + + qLog(Info) << "Sending" << all_urls.count() << "changes to gpodder.net"; + + QObject::connect(reply.data(), &mygpo::AddRemoveResult::finished, this, [this, all_urls]() { AddRemoveFinished(all_urls.values()); }); + QObject::connect(reply.data(), &mygpo::AddRemoveResult::parseError, this, &GPodderSync::AddRemoveParseError); + QObject::connect(reply.data(), &mygpo::AddRemoveResult::requestError, this, &GPodderSync::AddRemoveRequestError); +} + +void GPodderSync::AddRemoveParseError() { + + flushing_queue_ = false; + qLog(Warning) << "Failed to update gpodder subscriptions: parse error"; + +} + +void GPodderSync::AddRemoveRequestError(QNetworkReply::NetworkError err) { + + flushing_queue_ = false; + qLog(Warning) << "Failed to update gpodder subscriptions:" << err; + +} + +void GPodderSync::AddRemoveFinished(const QList &affected_urls) { + + flushing_queue_ = false; + + // Remove the URLs from the queue. + for (const QUrl &url : affected_urls) { + queued_add_subscriptions_.remove(url); + queued_remove_subscriptions_.remove(url); + } + + SaveQueue(); + + // Did more change in the mean time? + if (!queued_add_subscriptions_.isEmpty() || + !queued_remove_subscriptions_.isEmpty()) { + flush_queue_timer_->start(); + } + +} + +void GPodderSync::DoInitialSync() { + + // Get updates from the server + GetUpdatesNow(); + get_updates_timer_->start(); + + // Send our complete list of subscriptions + queued_remove_subscriptions_.clear(); + queued_add_subscriptions_.clear(); + for (const Podcast &podcast : backend_->GetAllSubscriptions()) { + queued_add_subscriptions_.insert(podcast.url()); + } + + SaveQueue(); + FlushUpdateQueue(); + +} diff --git a/src/podcasts/gpoddersync.h b/src/podcasts/gpoddersync.h new file mode 100644 index 00000000..8e2e5a5d --- /dev/null +++ b/src/podcasts/gpoddersync.h @@ -0,0 +1,125 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef GPODDERSYNC_H +#define GPODDERSYNC_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "podcastepisode.h" + +class QTimer; + +class Application; +class NetworkAccessManager; +class Podcast; +class PodcastBackend; +class PodcastUrlLoader; +class PodcastUrlLoaderReply; + +class GPodderSync : public QObject { + Q_OBJECT + + public: + explicit GPodderSync(Application *app, QObject *parent = nullptr); + ~GPodderSync(); + + static const char *kSettingsGroup; + static const int kFlushUpdateQueueDelay; + static const int kGetUpdatesInterval; + static const int kRequestTimeout; + + static QString DefaultDeviceName(); + static QString DeviceId(); + + bool is_logged_in() const; + + // Tries to login using the given username and password. Also sets the device name and type on gpodder.net. + // If login succeeds the username and password will be saved in QSettings. + void Login(const QString &username, const QString &password, const QString &device_name); + + // Clears any saved username and password from QSettings. + void Logout(); + + signals: + void LoginSuccess(); + void LoginFailure(const QString &error); + + public slots: + void GetUpdatesNow(); + + private slots: + void ReloadSettings(); + void LoginFinished(QNetworkReply *reply, const QString &username, const QString &password); + + void DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr reply); + void DeviceUpdatesParseError(); + void DeviceUpdatesRequestError(QNetworkReply::NetworkError error); + + void NewPodcastLoaded(PodcastUrlLoaderReply *reply, const QUrl &url, const QList &actions); + + void ApplyActions(const QList &actions, PodcastEpisodeList *episodes); + + void SubscriptionAdded(const Podcast &podcast); + void SubscriptionRemoved(const Podcast &podcast); + void FlushUpdateQueue(); + + void AddRemoveFinished(const QList &affected_urls); + void AddRemoveParseError(); + void AddRemoveRequestError(QNetworkReply::NetworkError error); + + private: + void LoadQueue(); + void SaveQueue(); + + void DoInitialSync(); + + private: + Application *app_; + NetworkAccessManager *network_; + QScopedPointer api_; + + PodcastBackend *backend_; + PodcastUrlLoader *loader_; + + QString username_; + QString password_; + QDateTime last_successful_get_; + QTimer *get_updates_timer_; + + QTimer *flush_queue_timer_; + QSet queued_add_subscriptions_; + QSet queued_remove_subscriptions_; + bool flushing_queue_; +}; + +#endif // GPODDERSYNC_H diff --git a/src/podcasts/gpoddertoptagsmodel.cpp b/src/podcasts/gpoddertoptagsmodel.cpp new file mode 100644 index 00000000..1f31abe4 --- /dev/null +++ b/src/podcasts/gpoddertoptagsmodel.cpp @@ -0,0 +1,115 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include + +#include + +#include "core/application.h" + +#include "gpoddertoptagsmodel.h" +#include "gpoddertoptagspage.h" +#include "podcast.h" + +GPodderTopTagsModel::GPodderTopTagsModel(mygpo::ApiRequest *api, Application *app, QObject *parent) + : PodcastDiscoveryModel(app, parent), api_(api) {} + +bool GPodderTopTagsModel::hasChildren(const QModelIndex &parent) const { + + if (parent.isValid() && parent.data(Role_Type).toInt() == Type_Folder) { + return true; + } + + return PodcastDiscoveryModel::hasChildren(parent); + +} + +bool GPodderTopTagsModel::canFetchMore(const QModelIndex &parent) const { + + if (parent.isValid() && parent.data(Role_Type).toInt() == Type_Folder && + !parent.data(Role_HasLazyLoaded).toBool()) { + return true; + } + + return PodcastDiscoveryModel::canFetchMore(parent); + +} + +void GPodderTopTagsModel::fetchMore(const QModelIndex &parent) { + + if (!parent.isValid() || parent.data(Role_Type).toInt() != Type_Folder || + parent.data(Role_HasLazyLoaded).toBool()) { + return; + } + setData(parent, true, Role_HasLazyLoaded); + + // Create a little Loading... item. + itemFromIndex(parent)->appendRow(CreateLoadingIndicator()); + + mygpo::PodcastListPtr list(api_->podcastsOfTag(GPodderTopTagsPage::kMaxTagCount, parent.data().toString())); + + QObject::connect(list.get(), &mygpo::PodcastList::finished, this, [this, parent, list]() { PodcastsOfTagFinished(parent, list.data()); }); + QObject::connect(list.get(), &mygpo::PodcastList::parseError, this, [this, parent, list]() { PodcastsOfTagFailed(parent, list.data()); }); + QObject::connect(list.get(), &mygpo::PodcastList::requestError, this, [this, parent, list]() { PodcastsOfTagFailed(parent, list.data()); }); + +} + +void GPodderTopTagsModel::PodcastsOfTagFinished(const QModelIndex &parent, mygpo::PodcastList *list) { + + QStandardItem *parent_item = itemFromIndex(parent); + if (!parent_item) return; + + // Remove the Loading... item. + while (parent_item->hasChildren()) { + parent_item->removeRow(0); + } + + for (mygpo::PodcastPtr gpo_podcast : list->list()) { + Podcast podcast; + podcast.InitFromGpo(gpo_podcast.data()); + + parent_item->appendRow(CreatePodcastItem(podcast)); + } + +} + +void GPodderTopTagsModel::PodcastsOfTagFailed(const QModelIndex &parent, mygpo::PodcastList*) { + + QStandardItem *parent_item = itemFromIndex(parent); + if (!parent_item) return; + + // Remove the Loading... item. + while (parent_item->hasChildren()) { + parent_item->removeRow(0); + } + + if (QMessageBox::warning(nullptr, tr("Failed to fetch podcasts"), tr("There was a problem communicating with gpodder.net"), QMessageBox::Retry | QMessageBox::Close, QMessageBox::Retry) != QMessageBox::Retry) { + return; + } + + // Try fetching the list again. + setData(parent, false, Role_HasLazyLoaded); + fetchMore(parent); + +} diff --git a/src/podcasts/gpoddertoptagsmodel.h b/src/podcasts/gpoddertoptagsmodel.h new file mode 100644 index 00000000..007632ec --- /dev/null +++ b/src/podcasts/gpoddertoptagsmodel.h @@ -0,0 +1,61 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef GPODDERTOPTAGSMODEL_H +#define GPODDERTOPTAGSMODEL_H + +#include + +#include "podcastdiscoverymodel.h" + +namespace mygpo { +class ApiRequest; +class PodcastList; +} // namespace mygpo + +class Application; + +class GPodderTopTagsModel : public PodcastDiscoveryModel { + Q_OBJECT + + public: + GPodderTopTagsModel(mygpo::ApiRequest *api, Application *app, QObject *parent = nullptr); + + enum Role { + Role_HasLazyLoaded = PodcastDiscoveryModel::RoleCount, + RoleCount + }; + + bool hasChildren(const QModelIndex &parent) const; + bool canFetchMore(const QModelIndex &parent) const; + void fetchMore(const QModelIndex &parent); + + private slots: + void PodcastsOfTagFinished(const QModelIndex &parent, mygpo::PodcastList *list); + void PodcastsOfTagFailed(const QModelIndex &parent, mygpo::PodcastList *list); + + private: + mygpo::ApiRequest *api_; +}; + +#endif // GPODDERTOPTAGSMODEL_H diff --git a/src/podcasts/gpoddertoptagspage.cpp b/src/podcasts/gpoddertoptagspage.cpp new file mode 100644 index 00000000..eab276ba --- /dev/null +++ b/src/podcasts/gpoddertoptagspage.cpp @@ -0,0 +1,93 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include + +#include "TagList.h" + +#include "core/application.h" +#include "core/networkaccessmanager.h" +#include "core/iconloader.h" + +#include "gpoddertoptagsmodel.h" +#include "gpoddertoptagspage.h" + +const int GPodderTopTagsPage::kMaxTagCount = 100; + +GPodderTopTagsPage::GPodderTopTagsPage(Application *app, QWidget *parent) + : AddPodcastPage(app, parent), + network_(new NetworkAccessManager(this)), + api_(new mygpo::ApiRequest(network_)), + done_initial_load_(false) { + + setWindowTitle(tr("gpodder.net directory")); + setWindowIcon(IconLoader::Load("mygpo")); + + SetModel(new GPodderTopTagsModel(api_, app, this)); + +} + +GPodderTopTagsPage::~GPodderTopTagsPage() { delete api_; } + +void GPodderTopTagsPage::Show() { + + if (!done_initial_load_) { + // Start the request for list of top-level tags + emit Busy(true); + done_initial_load_ = true; + + mygpo::TagListPtr tag_list(api_->topTags(kMaxTagCount)); + QObject::connect(tag_list.get(), &mygpo::TagList::finished, this, [this, tag_list]() { TagListLoaded(tag_list); }); + QObject::connect(tag_list.get(), &mygpo::TagList::parseError, this, [this]() { TagListFailed(); }); + QObject::connect(tag_list.get(), &mygpo::TagList::requestError, this, [this]() { TagListFailed(); }); + } + +} + +void GPodderTopTagsPage::TagListLoaded(mygpo::TagListPtr tag_list) { + + emit Busy(false); + + for (mygpo::TagPtr tag : tag_list->list()) { + model()->appendRow(model()->CreateFolder(tag->tag())); + } + +} + +void GPodderTopTagsPage::TagListFailed() { + + emit Busy(false); + done_initial_load_ = false; + + if (QMessageBox::warning( + nullptr, tr("Failed to fetch directory"), + tr("There was a problem communicating with gpodder.net"), + QMessageBox::Retry | QMessageBox::Close, + QMessageBox::Retry) != QMessageBox::Retry) { + return; + } + + // Try doing the search again. + Show(); + +} diff --git a/src/podcasts/gpoddertoptagspage.h b/src/podcasts/gpoddertoptagspage.h new file mode 100644 index 00000000..9457d855 --- /dev/null +++ b/src/podcasts/gpoddertoptagspage.h @@ -0,0 +1,59 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef GPODDERTOPTAGSPAGE_H +#define GPODDERTOPTAGSPAGE_H + +#include + +#include + +#include "addpodcastpage.h" + +class Application; +class NetworkAccessManager; + +class GPodderTopTagsPage : public AddPodcastPage { + Q_OBJECT + + public: + explicit GPodderTopTagsPage(Application *app, QWidget *parent = nullptr); + ~GPodderTopTagsPage(); + + static const int kMaxTagCount; + + virtual bool has_visible_widget() const { return false; } + virtual void Show(); + + private slots: + void TagListLoaded(mygpo::TagListPtr tag_list); + void TagListFailed(); + + private: + NetworkAccessManager *network_; + mygpo::ApiRequest *api_; + + bool done_initial_load_; +}; + +#endif // GPODDERTOPTAGSPAGE_H diff --git a/src/podcasts/itunessearchpage.cpp b/src/podcasts/itunessearchpage.cpp new file mode 100644 index 00000000..3b9ca076 --- /dev/null +++ b/src/podcasts/itunessearchpage.cpp @@ -0,0 +1,133 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/networkaccessmanager.h" +#include "core/iconloader.h" +#include "podcast.h" +#include "podcastdiscoverymodel.h" +#include "itunessearchpage.h" +#include "ui_itunessearchpage.h" + +const char* ITunesSearchPage::kUrlBase = "http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStoreServices.woa/wa/wsSearch?country=US&media=podcast"; + +ITunesSearchPage::ITunesSearchPage(Application* app, QWidget* parent) + : AddPodcastPage(app, parent), + ui_(new Ui_ITunesSearchPage), + network_(new NetworkAccessManager(this)) { + + ui_->setupUi(this); + + QObject::connect(ui_->search, &QPushButton::clicked, this, &ITunesSearchPage::SearchClicked); + setWindowIcon(IconLoader::Load("itunes")); + +} + +ITunesSearchPage::~ITunesSearchPage() { delete ui_; } + +void ITunesSearchPage::SearchClicked() { + + emit Busy(true); + + QUrl url(QUrl::fromEncoded(kUrlBase)); + QUrlQuery url_query; + url_query.addQueryItem("term", ui_->query->text()); + url.setQuery(url_query); + + QNetworkReply *reply = network_->get(QNetworkRequest(url)); + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { SearchFinished(reply); }); + +} + +void ITunesSearchPage::SearchFinished(QNetworkReply* reply) { + + reply->deleteLater(); + emit Busy(false); + + model()->clear(); + + // Was there a network error? + if (reply->error() != QNetworkReply::NoError) { + QMessageBox::warning(this, tr("Failed to fetch podcasts"), reply->errorString()); + return; + } + + QJsonParseError error; + QJsonDocument json_document = QJsonDocument::fromJson(reply->readAll(), &error); + + if (error.error != QJsonParseError::NoError) { + QMessageBox::warning(this, tr("Failed to fetch podcasts"), tr("There was a problem parsing the response from the iTunes Store")); + return; + } + + QJsonObject json_data = json_document.object(); + + // Was there an error message in the JSON? + if (json_data.contains("errorMessage")) { + QMessageBox::warning(this, tr("Failed to fetch podcasts"), json_data["errorMessage"].toString()); + return; + } + + QJsonArray array = json_data["results"].toArray(); + for (const QJsonValueRef &result : array) { + if (!result.isObject()) continue; + QJsonObject json_result = result.toObject(); + if (json_result["kind"].toString() != "podcast") { + continue; + } + + if (!json_result.contains("artistName") || + !json_result.contains("trackName") || + !json_result.contains("feedUrl") || + !json_result.contains("trackViewUrl") || + !json_result.contains("artworkUrl30") || + !json_result.contains("artworkUrl100")) { + continue; + } + + Podcast podcast; + podcast.set_author(json_result["artistName"].toString()); + podcast.set_title(json_result["trackName"].toString()); + podcast.set_url(QUrl(json_result["feedUrl"].toString())); + podcast.set_link(QUrl(json_result["trackViewUrl"].toString())); + podcast.set_image_url_small(QUrl(json_result["artworkUrl30"].toString())); + podcast.set_image_url_large(QUrl(json_result["artworkUrl100"].toString())); + + model()->appendRow(model()->CreatePodcastItem(podcast)); + } + +} + +void ITunesSearchPage::Show() { ui_->query->setFocus(); } diff --git a/src/podcasts/itunessearchpage.h b/src/podcasts/itunessearchpage.h new file mode 100644 index 00000000..a5b453bf --- /dev/null +++ b/src/podcasts/itunessearchpage.h @@ -0,0 +1,56 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef ITUNESSEARCHPAGE_H +#define ITUNESSEARCHPAGE_H + +#include "addpodcastpage.h" + +class Ui_ITunesSearchPage; + +class QNetworkReply; + +class NetworkAccessManager; + +class ITunesSearchPage : public AddPodcastPage { + Q_OBJECT + + public: + ITunesSearchPage(Application *app, QWidget *parent); + ~ITunesSearchPage(); + + void Show(); + + private slots: + void SearchClicked(); + void SearchFinished(QNetworkReply *reply); + + private: + static const char *kUrlBase; + + Ui_ITunesSearchPage *ui_; + + NetworkAccessManager *network_; +}; + +#endif // ITUNESSEARCHPAGE_H diff --git a/src/podcasts/itunessearchpage.ui b/src/podcasts/itunessearchpage.ui new file mode 100644 index 00000000..a474a230 --- /dev/null +++ b/src/podcasts/itunessearchpage.ui @@ -0,0 +1,61 @@ + + + ITunesSearchPage + + + + 0 + 0 + 516 + 69 + + + + Search iTunes + + + + 0 + + + + + Enter search terms below to find podcasts in the iTunes Store + + + + + + + + + + + + Search + + + + + + + + + + query + returnPressed() + search + click() + + + 237 + 52 + + + 461 + 55 + + + + + diff --git a/src/podcasts/opmlcontainer.h b/src/podcasts/opmlcontainer.h new file mode 100644 index 00000000..4c6d61ee --- /dev/null +++ b/src/podcasts/opmlcontainer.h @@ -0,0 +1,45 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef OPMLCONTAINER_H +#define OPMLCONTAINER_H + +#include +#include +#include + +#include "podcast.h" + +class OpmlContainer { + public: + // Only set for the top-level container + QUrl url; + + QString name; + QList containers; + PodcastList feeds; +}; + +Q_DECLARE_METATYPE(OpmlContainer) + +#endif // OPMLCONTAINER_H diff --git a/src/podcasts/podcast.cpp b/src/podcasts/podcast.cpp new file mode 100644 index 00000000..c6c0b2e9 --- /dev/null +++ b/src/podcasts/podcast.cpp @@ -0,0 +1,194 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/utilities.h" + +#include "podcast.h" + +#include + +const QStringList Podcast::kColumns = QStringList() << "url" + << "title" + << "description" + << "copyright" + << "link" + << "image_url_large" + << "image_url_small" + << "author" + << "owner_name" + << "owner_email" + << "last_updated" + << "last_update_error" + << "extra"; + +const QString Podcast::kColumnSpec = Podcast::kColumns.join(", "); +const QString Podcast::kJoinSpec = Utilities::Prepend("p.", Podcast::kColumns).join(", "); +const QString Podcast::kBindSpec = Utilities::Prepend(":", Podcast::kColumns).join(", "); +const QString Podcast::kUpdateSpec = Utilities::Updateify(Podcast::kColumns).join(", "); + +struct Podcast::Private : public QSharedData { + Private(); + + int database_id_; + QUrl url_; + + QString title_; + QString description_; + QString copyright_; + QUrl link_; + QUrl image_url_large_; + QUrl image_url_small_; + + // iTunes extensions + QString author_; + QString owner_name_; + QString owner_email_; + + QDateTime last_updated_; + QString last_update_error_; + + QVariantMap extra_; + + // These are stored in a different table + PodcastEpisodeList episodes_; +}; + +Podcast::Private::Private() : database_id_(-1) {} + +Podcast::Podcast() : d(new Private) {} + +Podcast::Podcast(const Podcast &other) : d(other.d) {} + +Podcast::~Podcast() {} + +Podcast &Podcast::operator=(const Podcast &other) { + d = other.d; + return *this; +} + +int Podcast::database_id() const { return d->database_id_; } +const QUrl &Podcast::url() const { return d->url_; } +const QString &Podcast::title() const { return d->title_; } +const QString &Podcast::description() const { return d->description_; } +const QString &Podcast::copyright() const { return d->copyright_; } +const QUrl &Podcast::link() const { return d->link_; } +const QUrl &Podcast::image_url_large() const { return d->image_url_large_; } +const QUrl &Podcast::image_url_small() const { return d->image_url_small_; } +const QString &Podcast::author() const { return d->author_; } +const QString &Podcast::owner_name() const { return d->owner_name_; } +const QString &Podcast::owner_email() const { return d->owner_email_; } +const QDateTime &Podcast::last_updated() const { return d->last_updated_; } +const QString &Podcast::last_update_error() const { + return d->last_update_error_; +} +const QVariantMap &Podcast::extra() const { return d->extra_; } +QVariant Podcast::extra(const QString &key) const { return d->extra_[key]; } + +void Podcast::set_database_id(const int v) { d->database_id_ = v; } +void Podcast::set_url(const QUrl &v) { d->url_ = v; } +void Podcast::set_title(const QString &v) { d->title_ = v; } +void Podcast::set_description(const QString &v) { d->description_ = v; } +void Podcast::set_copyright(const QString &v) { d->copyright_ = v; } +void Podcast::set_link(const QUrl &v) { d->link_ = v; } +void Podcast::set_image_url_large(const QUrl &v) { d->image_url_large_ = v; } +void Podcast::set_image_url_small(const QUrl &v) { d->image_url_small_ = v; } +void Podcast::set_author(const QString &v) { d->author_ = v; } +void Podcast::set_owner_name(const QString &v) { d->owner_name_ = v; } +void Podcast::set_owner_email(const QString &v) { d->owner_email_ = v; } +void Podcast::set_last_updated(const QDateTime &v) { d->last_updated_ = v; } +void Podcast::set_last_update_error(const QString &v) { d->last_update_error_ = v; } +void Podcast::set_extra(const QVariantMap &v) { d->extra_ = v; } +void Podcast::set_extra(const QString &key, const QVariant &value) { d->extra_[key] = value; } + +const PodcastEpisodeList &Podcast::episodes() const { return d->episodes_; } +PodcastEpisodeList* Podcast::mutable_episodes() { return &d->episodes_; } +void Podcast::set_episodes(const PodcastEpisodeList &v) { d->episodes_ = v; } +void Podcast::add_episode(const PodcastEpisode &episode) { d->episodes_.append(episode); } + +void Podcast::InitFromQuery(const QSqlQuery &query) { + + d->database_id_ = query.value(0).toInt(); + d->url_ = QUrl::fromEncoded(query.value(1).toByteArray()); + d->title_ = query.value(2).toString(); + d->description_ = query.value(3).toString(); + d->copyright_ = query.value(4).toString(); + d->link_ = QUrl::fromEncoded(query.value(5).toByteArray()); + d->image_url_large_ = QUrl::fromEncoded(query.value(6).toByteArray()); + d->image_url_small_ = QUrl::fromEncoded(query.value(7).toByteArray()); + d->author_ = query.value(8).toString(); + d->owner_name_ = query.value(9).toString(); + d->owner_email_ = query.value(10).toString(); + d->last_updated_ = QDateTime::fromSecsSinceEpoch(query.value(11).toUInt()); + d->last_update_error_ = query.value(12).toString(); + + QDataStream extra_stream(query.value(13).toByteArray()); + extra_stream >> d->extra_; + +} + +void Podcast::BindToQuery(QSqlQuery* query) const { + + query->bindValue(":url", d->url_.toEncoded()); + query->bindValue(":title", d->title_); + query->bindValue(":description", d->description_); + query->bindValue(":copyright", d->copyright_); + query->bindValue(":link", d->link_.toEncoded()); + query->bindValue(":image_url_large", d->image_url_large_.toEncoded()); + query->bindValue(":image_url_small", d->image_url_small_.toEncoded()); + query->bindValue(":author", d->author_); + query->bindValue(":owner_name", d->owner_name_); + query->bindValue(":owner_email", d->owner_email_); + query->bindValue(":last_updated", d->last_updated_.toSecsSinceEpoch()); + query->bindValue(":last_update_error", d->last_update_error_); + + QByteArray extra; + QDataStream extra_stream(&extra, QIODevice::WriteOnly); + extra_stream << d->extra_; + + query->bindValue(":extra", extra); + +} + +void Podcast::InitFromGpo(const mygpo::Podcast* podcast) { + + d->url_ = podcast->url(); + d->title_ = podcast->title(); + d->description_ = podcast->description(); + d->link_ = podcast->website(); + d->image_url_large_ = podcast->logoUrl(); + + set_extra("gpodder:subscribers", podcast->subscribers()); + set_extra("gpodder:subscribers_last_week", podcast->subscribersLastWeek()); + set_extra("gpodder:page", podcast->mygpoUrl()); + +} diff --git a/src/podcasts/podcast.h b/src/podcasts/podcast.h new file mode 100644 index 00000000..ade1e85a --- /dev/null +++ b/src/podcasts/podcast.h @@ -0,0 +1,114 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCAST_H +#define PODCAST_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "podcastepisode.h" + +namespace mygpo { +class Podcast; +} // namespace mygpo + +class Podcast { + public: + Podcast(); + Podcast(const Podcast &other); + ~Podcast(); + + static const QStringList kColumns; + static const QString kColumnSpec; + static const QString kJoinSpec; + static const QString kBindSpec; + static const QString kUpdateSpec; + + void InitFromQuery(const QSqlQuery &query); + void InitFromGpo(const mygpo::Podcast *podcast); + + void BindToQuery(QSqlQuery *query) const; + + bool is_valid() const { return database_id() != -1; } + + int database_id() const; + const QUrl &url() const; + const QString &title() const; + const QString &description() const; + const QString ©right() const; + const QUrl &link() const; + const QUrl &image_url_large() const; + const QUrl &image_url_small() const; + const QString &author() const; + const QString &owner_name() const; + const QString &owner_email() const; + const QDateTime &last_updated() const; + const QString &last_update_error() const; + const QVariantMap &extra() const; + QVariant extra(const QString &key) const; + + void set_database_id(const int v); + void set_url(const QUrl &v); + void set_title(const QString &v); + void set_description(const QString &v); + void set_copyright(const QString &v); + void set_link(const QUrl &v); + void set_image_url_large(const QUrl &v); + void set_image_url_small(const QUrl &v); + void set_author(const QString &v); + void set_owner_name(const QString &v); + void set_owner_email(const QString &v); + void set_last_updated(const QDateTime &v); + void set_last_update_error(const QString &v); + void set_extra(const QVariantMap &v); + void set_extra(const QString &key, const QVariant &value); + + // Small images are suitable for 16x16 icons in lists. Large images are used in detailed information displays. + const QUrl &ImageUrlLarge() const { return image_url_large().isValid() ? image_url_large() : image_url_small(); } + const QUrl &ImageUrlSmall() const { return image_url_small().isValid() ? image_url_small() : image_url_large(); } + + // These are stored in a different database table, and aren't loaded or persisted by InitFromQuery or BindToQuery. + const PodcastEpisodeList &episodes() const; + PodcastEpisodeList *mutable_episodes(); + void set_episodes(const PodcastEpisodeList &v); + void add_episode(const PodcastEpisode &episode); + + Podcast &operator=(const Podcast &other); + + private: + struct Private; + QSharedDataPointer d; +}; +Q_DECLARE_METATYPE(Podcast) + +typedef QList PodcastList; +Q_DECLARE_METATYPE(QList) + +#endif // PODCAST_H diff --git a/src/podcasts/podcastbackend.cpp b/src/podcasts/podcastbackend.cpp new file mode 100644 index 00000000..b60989e5 --- /dev/null +++ b/src/podcasts/podcastbackend.cpp @@ -0,0 +1,368 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include + +#include "core/application.h" +#include "core/database.h" +#include "core/logging.h" +#include "core/scopedtransaction.h" + +#include "podcastbackend.h" + +PodcastBackend::PodcastBackend(Application *app, QObject *parent) + : QObject(parent), app_(app), db_(app->database()) {} + +void PodcastBackend::Subscribe(Podcast *podcast) { + + // If this podcast is already in the database, do nothing + if (podcast->is_valid()) { + return; + } + + // If there's an entry in the database with the same URL, take its data. + Podcast existing_podcast = GetSubscriptionByUrl(podcast->url()); + if (existing_podcast.is_valid()) { + *podcast = existing_podcast; + return; + } + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + ScopedTransaction t(&db); + + // Insert the podcast. + QSqlQuery q(db); + q.prepare("INSERT INTO podcasts (" + Podcast::kColumnSpec + ") VALUES (" + Podcast::kBindSpec + ")"); + podcast->BindToQuery(&q); + + q.exec(); + if (db_->CheckErrors(q)) return; + + // Update the database ID. + const int database_id = q.lastInsertId().toInt(); + podcast->set_database_id(database_id); + + // Update the IDs of any episodes. + PodcastEpisodeList *episodes = podcast->mutable_episodes(); + for (auto it = episodes->begin(); it != episodes->end(); ++it) { + it->set_podcast_database_id(database_id); + } + + // Add those episodes to the database. + AddEpisodes(episodes, &db); + + t.Commit(); + + emit SubscriptionAdded(*podcast); +} + +void PodcastBackend::Unsubscribe(const Podcast &podcast) { + + // If this podcast is not already in the database, do nothing + if (!podcast.is_valid()) { + return; + } + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + ScopedTransaction t(&db); + + // Remove the podcast. + QSqlQuery q(db); + q.prepare("DELETE FROM podcasts WHERE ROWID = :id"); + q.bindValue(":id", podcast.database_id()); + q.exec(); + if (db_->CheckErrors(q)) return; + + // Remove all episodes in the podcast + q.prepare("DELETE FROM podcast_episodes WHERE podcast_id = :id"); + q.bindValue(":id", podcast.database_id()); + q.exec(); + if (db_->CheckErrors(q)) return; + + t.Commit(); + + emit SubscriptionRemoved(podcast); + +} + +void PodcastBackend::AddEpisodes(PodcastEpisodeList *episodes, QSqlDatabase *db) { + + QSqlQuery q(*db); + q.prepare("INSERT INTO podcast_episodes (" + PodcastEpisode::kColumnSpec + ") VALUES (" + PodcastEpisode::kBindSpec + ")"); + + for (auto it = episodes->begin(); it != episodes->end(); ++it) { + it->BindToQuery(&q); + q.exec(); + if (db_->CheckErrors(q)) continue; + + const int database_id = q.lastInsertId().toInt(); + it->set_database_id(database_id); + } + +} + +void PodcastBackend::AddEpisodes(PodcastEpisodeList *episodes) { + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + ScopedTransaction t(&db); + + AddEpisodes(episodes, &db); + t.Commit(); + + emit EpisodesAdded(*episodes); + +} + +void PodcastBackend::UpdateEpisodes(const PodcastEpisodeList &episodes) { + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + ScopedTransaction t(&db); + + QSqlQuery q(db); + q.prepare("UPDATE podcast_episodes SET listened = :listened, listened_date = :listened_date, downloaded = :downloaded, local_url = :local_url WHERE ROWID = :id"); + + for (const PodcastEpisode &episode : episodes) { + q.bindValue(":listened", episode.listened()); + q.bindValue(":listened_date", episode.listened_date().toSecsSinceEpoch()); + q.bindValue(":downloaded", episode.downloaded()); + q.bindValue(":local_url", episode.local_url().toEncoded()); + q.bindValue(":id", episode.database_id()); + q.exec(); + db_->CheckErrors(q); + } + + t.Commit(); + + emit EpisodesUpdated(episodes); + +} + +PodcastList PodcastBackend::GetAllSubscriptions() { + + PodcastList ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts"); + q.exec(); + if (db_->CheckErrors(q)) return ret; + + while (q.next()) { + Podcast podcast; + podcast.InitFromQuery(q); + ret << podcast; + } + + return ret; + +} + +Podcast PodcastBackend::GetSubscriptionById(const int id) { + + Podcast ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts WHERE ROWID = :id"); + q.bindValue(":id", id); + q.exec(); + if (!db_->CheckErrors(q) && q.next()) { + ret.InitFromQuery(q); + } + + return ret; + +} + +Podcast PodcastBackend::GetSubscriptionByUrl(const QUrl &url) { + + Podcast ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts WHERE url = :url"); + q.bindValue(":url", url.toEncoded()); + q.exec(); + if (!db_->CheckErrors(q) && q.next()) { + ret.InitFromQuery(q); + } + + return ret; + +} + +PodcastEpisodeList PodcastBackend::GetEpisodes(const int podcast_id) { + + PodcastEpisodeList ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE podcast_id = :id ORDER BY publication_date DESC"); + q.bindValue(":id", podcast_id); + q.exec(); + if (db_->CheckErrors(q)) return ret; + + while (q.next()) { + PodcastEpisode episode; + episode.InitFromQuery(q); + ret << episode; + } + + return ret; + +} + +PodcastEpisode PodcastBackend::GetEpisodeById(const int id) { + + PodcastEpisode ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE ROWID = :id"); + q.bindValue(":id", id); + q.exec(); + if (!db_->CheckErrors(q) && q.next()) { + ret.InitFromQuery(q); + } + + return ret; + +} + +PodcastEpisode PodcastBackend::GetEpisodeByUrl(const QUrl &url) { + + PodcastEpisode ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE url = :url"); + q.bindValue(":url", url.toEncoded()); + q.exec(); + if (!db_->CheckErrors(q) && q.next()) { + ret.InitFromQuery(q); + } + + return ret; + +} + +PodcastEpisode PodcastBackend::GetEpisodeByUrlOrLocalUrl(const QUrl &url) { + + PodcastEpisode ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE url = :url OR local_url = :url"); + q.bindValue(":url", url.toEncoded()); + q.exec(); + if (!db_->CheckErrors(q) && q.next()) { + ret.InitFromQuery(q); + } + + return ret; + +} + +PodcastEpisodeList PodcastBackend::GetOldDownloadedEpisodes(const QDateTime &max_listened_date) { + + PodcastEpisodeList ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE downloaded = 'true' AND listened_date <= :max_listened_date"); + q.bindValue(":max_listened_date", max_listened_date.toSecsSinceEpoch()); + q.exec(); + if (db_->CheckErrors(q)) return ret; + + while (q.next()) { + PodcastEpisode episode; + episode.InitFromQuery(q); + ret << episode; + } + + return ret; + +} + +PodcastEpisode PodcastBackend::GetOldestDownloadedListenedEpisode() { + + PodcastEpisode ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE downloaded = 'true' AND listened = 'true' ORDER BY listened_date ASC"); + q.exec(); + if (db_->CheckErrors(q)) return ret; + q.next(); + ret.InitFromQuery(q); + + return ret; + +} + +PodcastEpisodeList PodcastBackend::GetNewDownloadedEpisodes() { + + PodcastEpisodeList ret; + + QMutexLocker l(db_->Mutex()); + QSqlDatabase db(db_->Connect()); + + QSqlQuery q(db); + q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE downloaded = 'true' AND listened = 'false'"); + q.exec(); + if (db_->CheckErrors(q)) return ret; + + while (q.next()) { + PodcastEpisode episode; + episode.InitFromQuery(q); + ret << episode; + } + + return ret; + +} diff --git a/src/podcasts/podcastbackend.h b/src/podcasts/podcastbackend.h new file mode 100644 index 00000000..2d019814 --- /dev/null +++ b/src/podcasts/podcastbackend.h @@ -0,0 +1,98 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTBACKEND_H +#define PODCASTBACKEND_H + +#include +#include +#include + +#include "podcast.h" + +class QSqlDatabase; + +class Application; +class Database; + +class PodcastBackend : public QObject { + Q_OBJECT + + public: + explicit PodcastBackend(Application *app, QObject *parent = nullptr); + + // Adds the podcast and any included Episodes to the database. + // Updates the podcast with a database ID. + // If this podcast already has an ID set, this function does nothing. + // If a podcast with this URL already exists in the database, + // this function just updates the ID field in the provided podcast. + void Subscribe(Podcast *podcast); + + // Removes the Podcast with the given ID from the database. + // Also removes any episodes associated with this podcast. + void Unsubscribe(const Podcast &podcast); + + // Returns a list of all the subscribed podcasts. + // For efficiency the Podcast objects returned won't contain any PodcastEpisode objects - get them separately if you want them. + PodcastList GetAllSubscriptions(); + Podcast GetSubscriptionById(const int id); + Podcast GetSubscriptionByUrl(const QUrl &url); + + // Returns podcast episodes that match various keys. All these queries are indexed. + PodcastEpisodeList GetEpisodes(const int podcast_id); + PodcastEpisode GetEpisodeById(const int id); + PodcastEpisode GetEpisodeByUrl(const QUrl &url); + PodcastEpisode GetEpisodeByUrlOrLocalUrl(const QUrl &url); + PodcastEpisode GetOldestDownloadedListenedEpisode(); + + // Returns a list of episodes that have local data (downloaded=true) but were last listened to before the given QDateTime. + // This query is NOT indexed so it involves a full search of the table. + PodcastEpisodeList GetOldDownloadedEpisodes(const QDateTime &max_listened_date); + PodcastEpisodeList GetNewDownloadedEpisodes(); + + // Adds episodes to the database. Every episode must have a valid podcast_database_id set already. + void AddEpisodes(PodcastEpisodeList *episodes); + + // Updates the editable fields (listened, listened_date, downloaded, and local_url) on episodes that must already exist in the database. + void UpdateEpisodes(const PodcastEpisodeList &episodes); + + signals: + void SubscriptionAdded(const Podcast &podcast); + void SubscriptionRemoved(const Podcast &podcast); + + // Emitted when episodes are added to a subscription that *already exists*. + void EpisodesAdded(const PodcastEpisodeList &episodes); + + // Emitted when existing episodes are updated. + void EpisodesUpdated(const PodcastEpisodeList &episodes); + + private: + // Adds each episode to the database, setting their IDs after inserting each one. + void AddEpisodes(PodcastEpisodeList *episodes, QSqlDatabase *db); + + private: + Application *app_; + Database *db_; +}; + +#endif // PODCASTBACKEND_H diff --git a/src/podcasts/podcastdeleter.cpp b/src/podcasts/podcastdeleter.cpp new file mode 100644 index 00000000..aa42209a --- /dev/null +++ b/src/podcasts/podcastdeleter.cpp @@ -0,0 +1,124 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/timeconstants.h" +#include "core/utilities.h" +#include "collection/collectiondirectorymodel.h" +#include "collection/collectionmodel.h" +#include "podcastbackend.h" +#include "podcastdeleter.h" + +const char *PodcastDeleter::kSettingsGroup = "Podcasts"; +const int PodcastDeleter::kAutoDeleteCheckIntervalMsec = 60 * 6 * 60 * kMsecPerSec; + +PodcastDeleter::PodcastDeleter(Application *app, QObject *parent) + : QObject(parent), + app_(app), + backend_(app_->podcast_backend()), + delete_after_secs_(0), + auto_delete_timer_(new QTimer(this)) { + + ReloadSettings(); + auto_delete_timer_->setSingleShot(true); + AutoDelete(); + QObject::connect(auto_delete_timer_, &QTimer::timeout, this, &PodcastDeleter::AutoDelete); + QObject::connect(app_, &Application::SettingsChanged, this, &PodcastDeleter::ReloadSettings); + +} + +void PodcastDeleter::DeleteEpisode(const PodcastEpisode &episode) { + + // Delete the local file + if (!QFile::remove(episode.local_url().toLocalFile())) { + qLog(Warning) << "The local file" << episode.local_url().toLocalFile() << "could not be removed"; + } + + // Update the episode in the DB + PodcastEpisode episode_copy(episode); + episode_copy.set_downloaded(false); + episode_copy.set_local_url(QUrl()); + episode_copy.set_listened_date(QDateTime()); + backend_->UpdateEpisodes(PodcastEpisodeList() << episode_copy); + +} + +void PodcastDeleter::ReloadSettings() { + + QSettings s; + s.beginGroup(kSettingsGroup); + delete_after_secs_ = s.value("delete_after", 0).toInt(); + s.endGroup(); + + AutoDelete(); + +} + +void PodcastDeleter::AutoDelete() { + + if (delete_after_secs_ <= 0) { + return; + } + auto_delete_timer_->stop(); + QDateTime max_date = QDateTime::currentDateTime(); + qint64 timeout_ms; + PodcastEpisode oldest_episode; + QDateTime oldest_episode_time; + max_date = max_date.addSecs(-delete_after_secs_); + + PodcastEpisodeList old_episodes = backend_->GetOldDownloadedEpisodes(max_date); + + qLog(Info) << "Deleting" << old_episodes.count() + << "episodes because they were last listened to" + << (delete_after_secs_ / kSecsPerDay) << "days ago"; + + for (const PodcastEpisode& episode : old_episodes) { + DeleteEpisode(episode); + } + + oldest_episode = backend_->GetOldestDownloadedListenedEpisode(); + if (!oldest_episode.listened_date().isValid()) { + oldest_episode_time = QDateTime::currentDateTime(); + } + else { + oldest_episode_time = oldest_episode.listened_date(); + } + + timeout_ms = QDateTime::currentDateTime().toMSecsSinceEpoch(); + timeout_ms -= oldest_episode_time.toMSecsSinceEpoch(); + timeout_ms = (delete_after_secs_ * kMsecPerSec) - timeout_ms; + qLog(Info) << "Timeout for autodelete set to:" << timeout_ms << "ms"; + if (timeout_ms >= 0) { + auto_delete_timer_->setInterval(timeout_ms); + } + else { + auto_delete_timer_->setInterval(kAutoDeleteCheckIntervalMsec); + } + auto_delete_timer_->start(); + +} diff --git a/src/podcasts/podcastdeleter.h b/src/podcasts/podcastdeleter.h new file mode 100644 index 00000000..107a0fb1 --- /dev/null +++ b/src/podcasts/podcastdeleter.h @@ -0,0 +1,56 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTDELETER_H +#define PODCASTDELETER_H + +#include + +#include "podcast.h" +#include "podcastepisode.h" + +class QTimer; + +class Application; +class PodcastBackend; + +class PodcastDeleter : public QObject { + Q_OBJECT + + public: + explicit PodcastDeleter(Application *app, QObject *parent = nullptr); + static const char *kSettingsGroup; + static const int kAutoDeleteCheckIntervalMsec; + + public slots: + // Deletes downloaded data for this episode + void DeleteEpisode(const PodcastEpisode &episode); + void AutoDelete(); + void ReloadSettings(); + + private: + Application *app_; + PodcastBackend *backend_; + int delete_after_secs_; + QTimer *auto_delete_timer_; +}; + +#endif // PODCASTDELETER_H diff --git a/src/podcasts/podcastdiscoverymodel.cpp b/src/podcasts/podcastdiscoverymodel.cpp new file mode 100644 index 00000000..117b555c --- /dev/null +++ b/src/podcasts/podcastdiscoverymodel.cpp @@ -0,0 +1,125 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include "podcastdiscoverymodel.h" + +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/iconloader.h" +#include "core/standarditemiconloader.h" +#include "opmlcontainer.h" +#include "podcast.h" + +PodcastDiscoveryModel::PodcastDiscoveryModel(Application *app, QObject *parent) + : QStandardItemModel(parent), + app_(app), + icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)), + default_icon_(IconLoader::Load("podcast")) { + + icon_loader_->SetModel(this); + +} + +QVariant PodcastDiscoveryModel::data(const QModelIndex &idx, int role) const { + + if (idx.isValid() && role == Qt::DecorationRole && !QStandardItemModel::data(idx, Role_StartedLoadingImage).toBool()) { + const QUrl image_url = QStandardItemModel::data(idx, Role_ImageUrl).toUrl(); + if (image_url.isValid()) { + const_cast(this)->LazyLoadImage(image_url, idx); + } + } + + return QStandardItemModel::data(idx, role); + +} + +QStandardItem *PodcastDiscoveryModel::CreatePodcastItem(const Podcast &podcast) { + + QStandardItem *item = new QStandardItem; + item->setIcon(default_icon_); + item->setText(podcast.title()); + item->setData(QVariant::fromValue(podcast), Role_Podcast); + item->setData(Type_Podcast, Role_Type); + item->setData(podcast.ImageUrlSmall(), Role_ImageUrl); + return item; + +} + +QStandardItem *PodcastDiscoveryModel::CreateFolder(const QString &name) { + + if (folder_icon_.isNull()) { + folder_icon_ = IconLoader::Load("folder"); + } + + QStandardItem *item = new QStandardItem; + item->setIcon(folder_icon_); + item->setText(name); + item->setData(Type_Folder, Role_Type); + return item; + +} + +QStandardItem *PodcastDiscoveryModel::CreateOpmlContainerItem(const OpmlContainer &container) { + + QStandardItem *item = CreateFolder(container.name); + CreateOpmlContainerItems(container, item); + return item; + +} + +void PodcastDiscoveryModel::CreateOpmlContainerItems(const OpmlContainer &container, QStandardItem *parent) { + + for (const OpmlContainer &child : container.containers) { + QStandardItem *child_item = CreateOpmlContainerItem(child); + parent->appendRow(child_item); + } + + for (const Podcast &child : container.feeds) { + QStandardItem *child_item = CreatePodcastItem(child); + parent->appendRow(child_item); + } + +} + +void PodcastDiscoveryModel::LazyLoadImage(const QUrl &url, const QModelIndex &idx) { + + QStandardItem *item = itemFromIndex(idx); + item->setData(true, Role_StartedLoadingImage); + icon_loader_->LoadIcon(url, QUrl(), item); + +} + +QStandardItem *PodcastDiscoveryModel::CreateLoadingIndicator() { + + QStandardItem *item = new QStandardItem; + item->setText(tr("Loading...")); + item->setData(Type_LoadingIndicator, Role_Type); + return item; + +} diff --git a/src/podcasts/podcastdiscoverymodel.h b/src/podcasts/podcastdiscoverymodel.h new file mode 100644 index 00000000..a149ebc4 --- /dev/null +++ b/src/podcasts/podcastdiscoverymodel.h @@ -0,0 +1,79 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTDISCOVERYMODEL_H +#define PODCASTDISCOVERYMODEL_H + +#include +#include +#include +#include + +#include "covermanager/albumcoverloaderoptions.h" + +class Application; +class OpmlContainer; +class OpmlFeed; +class Podcast; +class StandardItemIconLoader; + +class PodcastDiscoveryModel : public QStandardItemModel { + Q_OBJECT + + public: + explicit PodcastDiscoveryModel(Application *app, QObject *parent = nullptr); + + enum Type { + Type_Folder, + Type_Podcast, + Type_LoadingIndicator + }; + + enum Role { + Role_Podcast = Qt::UserRole, + Role_Type, + Role_ImageUrl, + Role_StartedLoadingImage, + RoleCount + }; + + void CreateOpmlContainerItems(const OpmlContainer &container, QStandardItem *parent); + QStandardItem *CreateOpmlContainerItem(const OpmlContainer &container); + QStandardItem *CreatePodcastItem(const Podcast &podcast); + QStandardItem *CreateFolder(const QString &name); + QStandardItem *CreateLoadingIndicator(); + + QVariant data(const QModelIndex &idx, int role) const override; + + private: + void LazyLoadImage(const QUrl &url, const QModelIndex &idx); + + private: + Application *app_; + StandardItemIconLoader *icon_loader_; + + QIcon default_icon_; + QIcon folder_icon_; +}; + +#endif // PODCASTDISCOVERYMODEL_H diff --git a/src/podcasts/podcastdownloader.cpp b/src/podcasts/podcastdownloader.cpp new file mode 100644 index 00000000..ee056db8 --- /dev/null +++ b/src/podcasts/podcastdownloader.cpp @@ -0,0 +1,288 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include "podcastdownloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/tagreaderclient.h" +#include "core/timeconstants.h" +#include "core/utilities.h" +#include "podcastbackend.h" + +const char *PodcastDownloader::kSettingsGroup = "Podcasts"; + +Task::Task(const PodcastEpisode &episode, QFile *file, PodcastBackend *backend) + : file_(file), + episode_(episode), + backend_(backend), + network_(new NetworkAccessManager(this)), + req_(QNetworkRequest(episode.url())), + reply_(network_->get(req_)) { + + QObject::connect(reply_, &QNetworkReply::readyRead, this, &Task::reading); + QObject::connect(reply_, &QNetworkReply::finished, this, &Task::finishedInternal); + QObject::connect(reply_, &QNetworkReply::downloadProgress, this, &Task::downloadProgressInternal); + + emit ProgressChanged(episode_, PodcastDownload::Queued, 0); + +} + +PodcastEpisode Task::episode() const { return episode_; } + +void Task::reading() { + + qint64 bytes = 0; + forever { + bytes = reply_->bytesAvailable(); + if (bytes <= 0) break; + + file_->write(reply_->read(bytes)); + } + +} +void Task::finishedPublic() { + + disconnect(reply_, &QNetworkReply::readyRead, nullptr, nullptr); + disconnect(reply_, &QNetworkReply::downloadProgress, nullptr, nullptr); + disconnect(reply_, &QNetworkReply::finished, nullptr, nullptr); + + emit ProgressChanged(episode_, PodcastDownload::NotDownloading, 0); + + // Delete the file + file_->remove(); + + emit finished(this); + +} + +void Task::finishedInternal() { + + reply_->deleteLater(); + + if (reply_->error() != QNetworkReply::NoError) { + qLog(Warning) << "Error downloading episode:" << reply_->errorString(); + emit ProgressChanged(episode_, PodcastDownload::NotDownloading, 0); + // Delete the file + file_->remove(); + emit finished(this); + reply_ = nullptr; + return; + } + + qLog(Info) << "Download of" << file_->fileName() << "finished"; + + // Tell the database the episode has been updated. Get it from the DB again in case the listened field changed in the mean time. + PodcastEpisode episode = episode_; + episode.set_downloaded(true); + episode.set_local_url(QUrl::fromLocalFile(file_->fileName())); + backend_->UpdateEpisodes(PodcastEpisodeList() << episode); + Podcast podcast = backend_->GetSubscriptionById(episode.podcast_database_id()); + Song song = episode_.ToSong(podcast); + + emit ProgressChanged(episode_, PodcastDownload::Finished, 0); + + // I didn't ecountered even a single podcast with a correct metadata + TagReaderClient::Instance()->SaveFileBlocking(file_->fileName(), song); + emit finished(this); + + reply_ = nullptr; + +} + +void Task::downloadProgressInternal(qint64 received, qint64 total) { + + if (total <= 0) { + emit ProgressChanged(episode_, PodcastDownload::Downloading, 0); + } + else { + emit ProgressChanged(episode_, PodcastDownload::Downloading, static_cast(received) / total * 100); + } + +} + +PodcastDownloader::PodcastDownloader(Application *app, QObject *parent) + : QObject(parent), + app_(app), + backend_(app_->podcast_backend()), + network_(new NetworkAccessManager(this)), + disallowed_filename_characters_("[^a-zA-Z0-9_~ -]"), + auto_download_(false) { + + QObject::connect(backend_, &PodcastBackend::EpisodesAdded, this, &PodcastDownloader::EpisodesAdded); + QObject::connect(backend_, &PodcastBackend::SubscriptionAdded, this, &PodcastDownloader::SubscriptionAdded); + QObject::connect(app_, &Application::SettingsChanged, this, &PodcastDownloader::ReloadSettings); + + ReloadSettings(); + +} + +QString PodcastDownloader::DefaultDownloadDir() const { + + return QDir::homePath() + "/Podcasts"; + +} + +void PodcastDownloader::ReloadSettings() { + + QSettings s; + s.beginGroup(kSettingsGroup); + + auto_download_ = s.value("auto_download", false).toBool(); + download_dir_ = s.value("download_dir", DefaultDownloadDir()).toString(); + +} + +QString PodcastDownloader::FilenameForEpisode(const QString &directory, const PodcastEpisode &episode) const { + + const QString file_extension = QFileInfo(episode.url().path()).suffix(); + int count = 0; + + // The file name contains the publication date and episode title + QString base_filename = episode.publication_date().date().toString(Qt::ISODate) + "-" + SanitiseFilenameComponent(episode.title()); + + // Add numbers on to the end of the filename until we find one that doesn't exist. + forever { + QString filename; + + if (count == 0) { + filename = QString("%1/%2.%3").arg(directory, base_filename, file_extension); + } + else { + filename = QString("%1/%2 (%3).%4").arg(directory, base_filename, QString::number(count), file_extension); + } + + if (!QFile::exists(filename)) { + return filename; + } + + ++count; + } + +} + +void PodcastDownloader::DownloadEpisode(const PodcastEpisode &episode) { + + for (Task *tas : list_tasks_) { + if (tas->episode().database_id() == episode.database_id()) { + return; + } + } + + Podcast podcast = backend_->GetSubscriptionById(episode.podcast_database_id()); + if (!podcast.is_valid()) { + qLog(Warning) << "The podcast that contains episode" << episode.url() << "doesn't exist any more"; + return; + } + const QString directory = download_dir_ + "/" + SanitiseFilenameComponent(podcast.title()); + const QString filepath = FilenameForEpisode(directory, episode); + + // Open the output file + if (!QDir(directory).exists()) QDir().mkpath(directory); + QFile *file = new QFile(filepath); + if (!file->open(QIODevice::WriteOnly)) { + qLog(Warning) << "Could not open the file" << filepath << "for writing"; + return; + } + + Task *task = new Task(episode, file, backend_); + + list_tasks_ << task; + qLog(Info) << "Downloading" << task->episode().url() << "to" << filepath; + QObject::connect(task, &Task::finished, this, &PodcastDownloader::ReplyFinished); + QObject::connect(task, &Task::ProgressChanged, this, &PodcastDownloader::ProgressChanged); + +} + +void PodcastDownloader::ReplyFinished(Task *task) { + + list_tasks_.removeAll(task); + delete task; + +} + +QString PodcastDownloader::SanitiseFilenameComponent(const QString &text) const { + + return QString(text).replace(disallowed_filename_characters_, " ") .simplified(); + +} + +void PodcastDownloader::SubscriptionAdded(const Podcast &podcast) { + + EpisodesAdded(podcast.episodes()); + +} + +void PodcastDownloader::EpisodesAdded(const PodcastEpisodeList &episodes) { + + if (auto_download_) { + for (const PodcastEpisode &episode : episodes) { + DownloadEpisode(episode); + } + } + +} + +PodcastEpisodeList PodcastDownloader::EpisodesDownloading(const PodcastEpisodeList &episodes) { + + PodcastEpisodeList ret; + for (Task *tas : list_tasks_) { + for (const PodcastEpisode &episode : episodes) { + if (tas->episode().database_id() == episode.database_id()) { + ret << episode; + } + } + } + return ret; + +} + +void PodcastDownloader::cancelDownload(const PodcastEpisodeList &episodes) { + + QList ta; + for (Task *tas : list_tasks_) { + for (const PodcastEpisode &episode : episodes) { + if (tas->episode().database_id() == episode.database_id()) { + ta << tas; + } + } + } + for (Task *tas : ta) { + tas->finishedPublic(); + list_tasks_.removeAll(tas); + } + +} diff --git a/src/podcasts/podcastdownloader.h b/src/podcasts/podcastdownloader.h new file mode 100644 index 00000000..57491950 --- /dev/null +++ b/src/podcasts/podcastdownloader.h @@ -0,0 +1,129 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTDOWNLOADER_H +#define PODCASTDOWNLOADER_H + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/networkaccessmanager.h" +#include "podcast.h" +#include "podcastepisode.h" + +class Application; +class PodcastBackend; + +class NetworkAccessManager; +class QNetworkReply; + +namespace PodcastDownload { +enum State { + NotDownloading, + Queued, + Downloading, + Finished +}; +} + +class Task : public QObject { + Q_OBJECT + + public: + Task(const PodcastEpisode &episode, QFile *file, PodcastBackend *backend); + PodcastEpisode episode() const; + + signals: + void ProgressChanged(const PodcastEpisode &episode, const PodcastDownload::State state, const int percent); + void finished(Task *task); + + public slots: + void finishedPublic(); + + private slots: + void reading(); + void downloadProgressInternal(qint64 received, qint64 total); + void finishedInternal(); + + private: + std::unique_ptr file_; + PodcastEpisode episode_; + PodcastBackend *backend_; + std::unique_ptr network_; + QNetworkRequest req_; + QNetworkReply *reply_; +}; + +class PodcastDownloader : public QObject { + Q_OBJECT + + public: + explicit PodcastDownloader(Application *app, QObject *parent = nullptr); + + PodcastEpisodeList EpisodesDownloading(const PodcastEpisodeList &episodes); + QString DefaultDownloadDir() const; + + public slots: + // Adds the episode to the download queue + void DownloadEpisode(const PodcastEpisode &episode); + void cancelDownload(const PodcastEpisodeList &episodes); + + signals: + void ProgressChanged(const PodcastEpisode &episode, const PodcastDownload::State state, const int percent); + + private slots: + void ReloadSettings(); + + void SubscriptionAdded(const Podcast &podcast); + void EpisodesAdded(const PodcastEpisodeList &episodes); + + void ReplyFinished(Task *task); + + private: + QString FilenameForEpisode(const QString &directory, const PodcastEpisode &episode) const; + QString SanitiseFilenameComponent(const QString &text) const; + + private: + static const char *kSettingsGroup; + + Application *app_; + PodcastBackend *backend_; + NetworkAccessManager *network_; + + QRegularExpression disallowed_filename_characters_; + + bool auto_download_; + QString download_dir_; + + QList list_tasks_; +}; + +#endif // PODCASTDOWNLOADER_H diff --git a/src/podcasts/podcastepisode.cpp b/src/podcasts/podcastepisode.cpp new file mode 100644 index 00000000..5c8d3102 --- /dev/null +++ b/src/podcasts/podcastepisode.cpp @@ -0,0 +1,231 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/timeconstants.h" +#include "core/utilities.h" +#include "podcast.h" +#include "podcastepisode.h" + +const QStringList PodcastEpisode::kColumns = QStringList() << "podcast_id" + << "title" + << "description" + << "author" + << "publication_date" + << "duration_secs" + << "url" + << "listened" + << "listened_date" + << "downloaded" + << "local_url" + << "extra"; + +const QString PodcastEpisode::kColumnSpec = PodcastEpisode::kColumns.join(", "); +const QString PodcastEpisode::kJoinSpec = Utilities::Prepend("e.", PodcastEpisode::kColumns).join(", "); +const QString PodcastEpisode::kBindSpec = Utilities::Prepend(":", PodcastEpisode::kColumns).join(", "); +const QString PodcastEpisode::kUpdateSpec = Utilities::Updateify(PodcastEpisode::kColumns).join(", "); + +struct PodcastEpisode::Private : public QSharedData { + Private(); + + int database_id_; + int podcast_database_id_; + + QString title_; + QString description_; + QString author_; + QDateTime publication_date_; + int duration_secs_; + QUrl url_; + + bool listened_; + QDateTime listened_date_; + + bool downloaded_; + QUrl local_url_; + + QVariantMap extra_; +}; + +PodcastEpisode::Private::Private() + : database_id_(-1), + podcast_database_id_(-1), + duration_secs_(-1), + listened_(false), + downloaded_(false) {} + +PodcastEpisode::PodcastEpisode() : d(new Private) {} + +PodcastEpisode::PodcastEpisode(const PodcastEpisode &other) : d(other.d) {} + +PodcastEpisode::~PodcastEpisode() {} + +PodcastEpisode &PodcastEpisode::operator=(const PodcastEpisode &other) { + d = other.d; + return *this; +} + +int PodcastEpisode::database_id() const { return d->database_id_; } +int PodcastEpisode::podcast_database_id() const { + return d->podcast_database_id_; +} +const QString &PodcastEpisode::title() const { return d->title_; } +const QString &PodcastEpisode::description() const { return d->description_; } +const QString &PodcastEpisode::author() const { return d->author_; } +const QDateTime &PodcastEpisode::publication_date() const { return d->publication_date_; } +int PodcastEpisode::duration_secs() const { return d->duration_secs_; } +const QUrl &PodcastEpisode::url() const { return d->url_; } +bool PodcastEpisode::listened() const { return d->listened_; } +const QDateTime &PodcastEpisode::listened_date() const { return d->listened_date_; } +bool PodcastEpisode::downloaded() const { return d->downloaded_; } +const QUrl &PodcastEpisode::local_url() const { return d->local_url_; } +const QVariantMap &PodcastEpisode::extra() const { return d->extra_; } +QVariant PodcastEpisode::extra(const QString &key) const { return d->extra_[key]; } + +void PodcastEpisode::set_database_id(const int v) { d->database_id_ = v; } +void PodcastEpisode::set_podcast_database_id(const int v) { d->podcast_database_id_ = v; } +void PodcastEpisode::set_title(const QString &v) { d->title_ = v; } +void PodcastEpisode::set_description(const QString &v) { d->description_ = v; } +void PodcastEpisode::set_author(const QString &v) { d->author_ = v; } +void PodcastEpisode::set_publication_date(const QDateTime &v) { d->publication_date_ = v; } +void PodcastEpisode::set_duration_secs(int v) { d->duration_secs_ = v; } +void PodcastEpisode::set_url(const QUrl &v) { d->url_ = v; } +void PodcastEpisode::set_listened(const bool v) { d->listened_ = v; } +void PodcastEpisode::set_listened_date(const QDateTime &v) { d->listened_date_ = v; } +void PodcastEpisode::set_downloaded(const bool v) { d->downloaded_ = v; } +void PodcastEpisode::set_local_url(const QUrl &v) { d->local_url_ = v; } +void PodcastEpisode::set_extra(const QVariantMap &v) { d->extra_ = v; } +void PodcastEpisode::set_extra(const QString &key, const QVariant &value) { d->extra_[key] = value; } + +void PodcastEpisode::InitFromQuery(const QSqlQuery &query) { + + d->database_id_ = query.value(0).toInt(); + d->podcast_database_id_ = query.value(1).toInt(); + d->title_ = query.value(2).toString(); + d->description_ = query.value(3).toString(); + d->author_ = query.value(4).toString(); + d->publication_date_ = QDateTime::fromSecsSinceEpoch(query.value(5).toUInt()); + d->duration_secs_ = query.value(6).toInt(); + d->url_ = QUrl::fromEncoded(query.value(7).toByteArray()); + d->listened_ = query.value(8).toBool(); + + // After setting QDateTime to invalid state, it's saved into database as time_t, + // when this number std::numeric_limits::max() (4294967295) is read back from database, it creates a valid QDateTime. + // So to make it behave consistently, this change is needed. + if (query.value(9).toUInt() == std::numeric_limits::max()) { + d->listened_date_ = QDateTime(); + } + else { + d->listened_date_ = QDateTime::fromSecsSinceEpoch(query.value(9).toUInt()); + } + + d->downloaded_ = query.value(10).toBool(); + d->local_url_ = QUrl::fromEncoded(query.value(11).toByteArray()); + + QDataStream extra_stream(query.value(12).toByteArray()); + extra_stream >> d->extra_; + +} + +void PodcastEpisode::BindToQuery(QSqlQuery* query) const { + + query->bindValue(":podcast_id", d->podcast_database_id_); + query->bindValue(":title", d->title_); + query->bindValue(":description", d->description_); + query->bindValue(":author", d->author_); + query->bindValue(":publication_date", d->publication_date_.toSecsSinceEpoch()); + query->bindValue(":duration_secs", d->duration_secs_); + query->bindValue(":url", d->url_.toEncoded()); + query->bindValue(":listened", d->listened_); + query->bindValue(":listened_date", d->listened_date_.toSecsSinceEpoch()); + query->bindValue(":downloaded", d->downloaded_); + query->bindValue(":local_url", d->local_url_.toEncoded()); + + QByteArray extra; + QDataStream extra_stream(&extra, QIODevice::WriteOnly); + extra_stream << d->extra_; + + query->bindValue(":extra", extra); + +} + +Song PodcastEpisode::ToSong(const Podcast &podcast) const { + + Song ret; + ret.set_valid(true); + ret.set_title(title().simplified()); + ret.set_artist(author().simplified()); + ret.set_length_nanosec(kNsecPerSec * duration_secs()); + ret.set_year(publication_date().date().year()); + ret.set_comment(description()); + ret.set_id(database_id()); + ret.set_ctime(publication_date().toSecsSinceEpoch()); + ret.set_genre(QString("Podcast")); + //ret.set_genre_id3(186); + + if (listened() && listened_date().isValid()) { + ret.set_mtime(listened_date().toSecsSinceEpoch()); + } + else { + ret.set_mtime(publication_date().toSecsSinceEpoch()); + } + + if (ret.length_nanosec() < 0) { + ret.set_length_nanosec(-1); + } + + if (downloaded() && QFile::exists(local_url().toLocalFile())) { + ret.set_url(local_url()); + } + else { + ret.set_url(url()); + } + + ret.set_basefilename(QFileInfo(ret.url().path()).fileName()); + + // Use information from the podcast if it's set + if (podcast.is_valid()) { + ret.set_album(podcast.title().simplified()); + ret.set_art_automatic(podcast.ImageUrlLarge()); + + if (author().isEmpty()) ret.set_artist(podcast.title().simplified()); + } + return ret; + +} diff --git a/src/podcasts/podcastepisode.h b/src/podcasts/podcastepisode.h new file mode 100644 index 00000000..80d17c3a --- /dev/null +++ b/src/podcasts/podcastepisode.h @@ -0,0 +1,100 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTEPISODE_H +#define PODCASTEPISODE_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/song.h" + +class Podcast; + +class PodcastEpisode { + public: + PodcastEpisode(); + PodcastEpisode(const PodcastEpisode &other); + ~PodcastEpisode(); + + static const QStringList kColumns; + static const QString kColumnSpec; + static const QString kJoinSpec; + static const QString kBindSpec; + static const QString kUpdateSpec; + + void InitFromQuery(const QSqlQuery &query); + void BindToQuery(QSqlQuery *query) const; + + Song ToSong(const Podcast &podcast) const; + + bool is_valid() const { return database_id() != -1; } + + int database_id() const; + int podcast_database_id() const; + const QString &title() const; + const QString &description() const; + const QString &author() const; + const QDateTime &publication_date() const; + int duration_secs() const; + const QUrl &url() const; + bool listened() const; + const QDateTime &listened_date() const; + bool downloaded() const; + const QUrl &local_url() const; + const QVariantMap &extra() const; + QVariant extra(const QString &key) const; + + void set_database_id(const int v); + void set_podcast_database_id(int v); + void set_title(const QString &v); + void set_description(const QString &v); + void set_author(const QString &v); + void set_publication_date(const QDateTime &v); + void set_duration_secs(int v); + void set_url(const QUrl &v); + void set_listened(const bool v); + void set_listened_date(const QDateTime &v); + void set_downloaded(const bool v); + void set_local_url(const QUrl &v); + void set_extra(const QVariantMap &v); + void set_extra(const QString &key, const QVariant &value); + + PodcastEpisode &operator=(const PodcastEpisode &other); + + private: + struct Private; + QSharedDataPointer d; +}; +Q_DECLARE_METATYPE(PodcastEpisode) + +typedef QList PodcastEpisodeList; +Q_DECLARE_METATYPE(QList) + +#endif // PODCASTEPISODE_H diff --git a/src/podcasts/podcastinfodialog.cpp b/src/podcasts/podcastinfodialog.cpp new file mode 100644 index 00000000..a8ffd1d7 --- /dev/null +++ b/src/podcasts/podcastinfodialog.cpp @@ -0,0 +1,59 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + Copyright 2018, Jim Broadus + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include + +#include "core/application.h" +#include "podcastepisode.h" +#include "podcastinfodialog.h" +#include "ui_podcastinfodialog.h" + +PodcastInfoDialog::PodcastInfoDialog(Application *app, QWidget *parent) + : QDialog(parent), app_(app), ui_(new Ui_PodcastInfoDialog) { + + ui_->setupUi(this); + ui_->podcast_details->SetApplication(app); + ui_->episode_details->SetApplication(app); + +} + +PodcastInfoDialog::~PodcastInfoDialog() { delete ui_; } + +void PodcastInfoDialog::ShowPodcast(const Podcast &podcast) { + + ui_->episode_info_scroll_area->hide(); + ui_->podcast_url->setText(podcast.url().toString()); + ui_->podcast_url->setReadOnly(true); + ui_->podcast_details->SetPodcast(podcast); + show(); + +} + +void PodcastInfoDialog::ShowEpisode(const PodcastEpisode &episode, const Podcast &podcast) { + + ui_->episode_info_scroll_area->show(); + ui_->podcast_url->setText(episode.url().toString()); + ui_->podcast_url->setReadOnly(true); + ui_->podcast_details->SetPodcast(podcast); + ui_->episode_details->SetEpisode(episode); + show(); + +} diff --git a/src/podcasts/podcastinfodialog.h b/src/podcasts/podcastinfodialog.h new file mode 100644 index 00000000..7fe76876 --- /dev/null +++ b/src/podcasts/podcastinfodialog.h @@ -0,0 +1,48 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + Copyright 2018, Jim Broadus + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTINFODIALOG_H +#define PODCASTINFODIALOG_H + +#include + +class Application; +class Podcast; +class PodcastEpisode; +class Ui_PodcastInfoDialog; + +class PodcastInfoDialog : public QDialog { + Q_OBJECT + + public: + explicit PodcastInfoDialog(Application *app, QWidget *parent = nullptr); + ~PodcastInfoDialog(); + + void ShowPodcast(const Podcast &podcast); + void ShowEpisode(const PodcastEpisode &episode, const Podcast &podcast); + + private: + Application *app_; + + Ui_PodcastInfoDialog *ui_; +}; + +#endif // PODCASTINFODIALOG_H diff --git a/src/podcasts/podcastinfodialog.ui b/src/podcasts/podcastinfodialog.ui new file mode 100644 index 00000000..40077ec7 --- /dev/null +++ b/src/podcasts/podcastinfodialog.ui @@ -0,0 +1,142 @@ + + + PodcastInfoDialog + + + + 0 + 0 + 493 + 415 + + + + Podcast Information + + + + + + + + + true + + + + 250 + 100 + + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 473 + 158 + + + + + + + + + + 250 + 100 + + + + + 16777215 + 16777215 + + + + Qt::ScrollBarAlwaysOff + + + true + + + + + 0 + 0 + 473 + 157 + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + + PodcastInfoWidget + QWidget +
podcasts/podcastinfowidget.h
+ 1 +
+ + EpisodeInfoWidget + QWidget +
podcasts/episodeinfowidget.h
+ 1 +
+
+ + + + buttonBox + accepted() + PodcastInfoDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + PodcastInfoDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
diff --git a/src/podcasts/podcastinfowidget.cpp b/src/podcasts/podcastinfowidget.cpp new file mode 100644 index 00000000..8d2361d4 --- /dev/null +++ b/src/podcasts/podcastinfowidget.cpp @@ -0,0 +1,134 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "covermanager/albumcoverloader.h" +#include "covermanager/albumcoverloaderoptions.h" +#include "covermanager/albumcoverloaderresult.h" +#include "podcastinfowidget.h" +#include "ui_podcastinfowidget.h" + +PodcastInfoWidget::PodcastInfoWidget(QWidget *parent) + : QWidget(parent), + ui_(new Ui_PodcastInfoWidget), + app_(nullptr), + image_id_(0) { + + ui_->setupUi(this); + + cover_options_.desired_height_ = 180; + ui_->image->setFixedSize(cover_options_.desired_height_, cover_options_.desired_height_); + + // Set the colour of all the labels + const bool light = palette().color(QPalette::Base).value() > 128; + const QColor color = palette().color(QPalette::Dark); + QPalette label_palette(palette()); + label_palette.setColor(QPalette::WindowText, light ? color.darker(150) : color.lighter(125)); + + for (QLabel* label : findChildren()) { + if (label->property("field_label").toBool()) { + label->setPalette(label_palette); + } + } + +} + +PodcastInfoWidget::~PodcastInfoWidget() { delete ui_; } + +void PodcastInfoWidget::SetApplication(Application *app) { + + app_ = app; + connect(app_->album_cover_loader(), &AlbumCoverLoader::AlbumCoverLoaded, this, &PodcastInfoWidget::AlbumCoverLoaded); + +} + +namespace { +template +void SetText(const QString& value, T* label, QLabel* buddy_label = nullptr) { + + const bool visible = !value.isEmpty(); + + label->setVisible(visible); + if (buddy_label) { + buddy_label->setVisible(visible); + } + + if (visible) { + label->setText(value); + } + +} +} // namespace + +void PodcastInfoWidget::SetPodcast(const Podcast &podcast) { + + if (image_id_) { + app_->album_cover_loader()->CancelTask(image_id_); + image_id_ = 0; + } + + podcast_ = podcast; + + if (podcast.ImageUrlLarge().isValid()) { + // Start loading an image for this item. + image_id_ = app_->album_cover_loader()->LoadImageAsync(cover_options_, podcast.ImageUrlLarge(), QUrl()); + } + + ui_->image->hide(); + + SetText(podcast.title(), ui_->title); + SetText(podcast.description(), ui_->description); + SetText(podcast.copyright(), ui_->copyright, ui_->copyright_label); + SetText(podcast.author(), ui_->author, ui_->author_label); + SetText(podcast.owner_name(), ui_->owner, ui_->owner_label); + SetText(podcast.link().toString(), ui_->website, ui_->website_label); + SetText(podcast.extra("gpodder:subscribers").toString(), ui_->subscribers, ui_->subscribers_label); + + if (!image_id_) { + emit LoadingFinished(); + } + +} + +void PodcastInfoWidget::AlbumCoverLoaded(const quint64 id, const AlbumCoverLoaderResult &result) { + + if (id != image_id_) { + return; + } + image_id_ = 0; + + if (result.success && !result.image_scaled.isNull()) { + ui_->image->setPixmap(QPixmap::fromImage(result.image_scaled)); + ui_->image->show(); + } + + emit LoadingFinished(); + +} diff --git a/src/podcasts/podcastinfowidget.h b/src/podcasts/podcastinfowidget.h new file mode 100644 index 00000000..590eb459 --- /dev/null +++ b/src/podcasts/podcastinfowidget.h @@ -0,0 +1,65 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTINFOWIDGET_H +#define PODCASTINFOWIDGET_H + +#include + +#include "podcast.h" +#include "covermanager/albumcoverloaderoptions.h" +#include "covermanager/albumcoverloaderresult.h" + +class Application; +class Ui_PodcastInfoWidget; + +class QLabel; + +class PodcastInfoWidget : public QWidget { + Q_OBJECT + + public: + explicit PodcastInfoWidget(QWidget *parent = nullptr); + ~PodcastInfoWidget(); + + void SetApplication(Application *app); + + void SetPodcast(const Podcast& podcast); + + signals: + void LoadingFinished(); + + private slots: + void AlbumCoverLoaded(const quint64 id, const AlbumCoverLoaderResult &result); + + private: + Ui_PodcastInfoWidget *ui_; + + AlbumCoverLoaderOptions cover_options_; + + Application *app_; + Podcast podcast_; + quint64 image_id_; +}; + +#endif // PODCASTINFOWIDGET_H diff --git a/src/podcasts/podcastinfowidget.ui b/src/podcasts/podcastinfowidget.ui new file mode 100644 index 00000000..0f437ee4 --- /dev/null +++ b/src/podcasts/podcastinfowidget.ui @@ -0,0 +1,220 @@ + + + PodcastInfoWidget + + + + 0 + 0 + 398 + 551 + + + + Form + + + #title { + font-weight: bold; +} + +#description { + font-size: smaller; +} + +QLineEdit { + background: transparent; +} + + + + QLayout::SetMinAndMaxSize + + + + + 0 + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + true + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + QLayout::SetMinAndMaxSize + + + + + false + + + true + + + + + + + Author + + + true + + + + + + + false + + + true + + + + + + + Website + + + true + + + + + + + false + + + true + + + + + + + Copyright + + + true + + + + + + + false + + + true + + + + + + + Owner + + + true + + + + + + + Subscribers + + + true + + + + + + + false + + + true + + + + + + + + + Qt::Vertical + + + + 0 + 0 + + + + + + + + + diff --git a/src/podcasts/podcastparser.cpp b/src/podcasts/podcastparser.cpp new file mode 100644 index 00000000..b7f68924 --- /dev/null +++ b/src/podcasts/podcastparser.cpp @@ -0,0 +1,376 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/utilities.h" +#include "podcastparser.h" +#include "opmlcontainer.h" + +// Namespace constants must be lower case. +const char *PodcastParser::kAtomNamespace = "http://www.w3.org/2005/atom"; +const char *PodcastParser::kItunesNamespace = "http://www.itunes.com/dtds/podcast-1.0.dtd"; + +PodcastParser::PodcastParser() { + supported_mime_types_ << "application/rss+xml" + << "application/xml" + << "text/x-opml" + << "text/xml"; +} + +bool PodcastParser::SupportsContentType(const QString &content_type) const { + + if (content_type.isEmpty()) { + // Why not have a go. + return true; + } + + for (const QString &mime_type : supported_mime_types()) { + if (content_type.contains(mime_type)) { + return true; + } + } + return false; + +} + +bool PodcastParser::TryMagic(const QByteArray &data) const { + QString str(QString::fromUtf8(data)); + return str.contains(QRegularExpression("atEnd()) { + QXmlStreamReader::TokenType type = reader->readNext(); + switch (type) { + case QXmlStreamReader::StartElement: { + const QString name = reader->name().toString(); + const QString lower_namespace = reader->namespaceUri().toString().toLower(); + + if (name == "title") { + ret->set_title(reader->readElementText()); + } + else if (name == "link" && lower_namespace.isEmpty()) { + ret->set_link(QUrl::fromEncoded(reader->readElementText().toLatin1())); + } + else if (name == "description") { + ret->set_description(reader->readElementText()); + } + else if (name == "owner" && lower_namespace == kItunesNamespace) { + ParseItunesOwner(reader, ret); + } + else if (name == "image") { + ParseImage(reader, ret); + } + else if (name == "copyright") { + ret->set_copyright(reader->readElementText()); + } + else if (name == "link" && lower_namespace == kAtomNamespace && ret->url().isEmpty() && reader->attributes().value("rel").toString() == "self") { + ret->set_url(QUrl::fromEncoded(reader->readElementText().toLatin1())); + } + else if (name == "item") { + ParseItem(reader, ret); + } + else { + Utilities::ConsumeCurrentElement(reader); + } + break; + } + + case QXmlStreamReader::EndElement: + return; + + default: + break; + } + } + +} + +void PodcastParser::ParseImage(QXmlStreamReader *reader, Podcast *ret) const { + + while (!reader->atEnd()) { + QXmlStreamReader::TokenType type = reader->readNext(); + switch (type) { + case QXmlStreamReader::StartElement: { + const QString name = reader->name().toString(); + if (name == "url") { + ret->set_image_url_large( + QUrl::fromEncoded(reader->readElementText().toLatin1())); + } + else { + Utilities::ConsumeCurrentElement(reader); + } + break; + } + + case QXmlStreamReader::EndElement: + return; + + default: + break; + } + } + +} + +void PodcastParser::ParseItunesOwner(QXmlStreamReader *reader, Podcast *ret) const { + + while (!reader->atEnd()) { + QXmlStreamReader::TokenType type = reader->readNext(); + switch (type) { + case QXmlStreamReader::StartElement: { + const QString name = reader->name().toString(); + if (name == "name") { + ret->set_owner_name(reader->readElementText()); + } + else if (name == "email") { + ret->set_owner_email(reader->readElementText()); + } + else { + Utilities::ConsumeCurrentElement(reader); + } + break; + } + + case QXmlStreamReader::EndElement: + return; + + default: + break; + } + } + +} + +void PodcastParser::ParseItem(QXmlStreamReader *reader, Podcast *ret) const { + + PodcastEpisode episode; + + while (!reader->atEnd()) { + QXmlStreamReader::TokenType type = reader->readNext(); + switch (type) { + case QXmlStreamReader::StartElement: { + const QString name = reader->name().toString(); + const QString lower_namespace = reader->namespaceUri().toString().toLower(); + + if (name == "title") { + episode.set_title(reader->readElementText()); + } + else if (name == "description") { + episode.set_description(reader->readElementText()); + } + else if (name == "pubDate") { + QString date = reader->readElementText(); + episode.set_publication_date(Utilities::ParseRFC822DateTime(date)); + if (!episode.publication_date().isValid()) { + qLog(Error) << "Unable to parse date:" << date; + } + } + else if (name == "duration" && lower_namespace == kItunesNamespace) { + // http://www.apple.com/itunes/podcasts/specs.html + QStringList parts = reader->readElementText().split(':'); + if (parts.count() == 2) { + episode.set_duration_secs(parts[0].toInt() * 60 + parts[1].toInt()); + } + else if (parts.count() >= 3) { + episode.set_duration_secs(parts[0].toInt() * 60 * 60 + parts[1].toInt() * 60 + parts[2].toInt()); + } + } + else if (name == "enclosure") { + const QString type2 = reader->attributes().value("type").toString(); + const QUrl url = QUrl::fromEncoded(reader->attributes().value("url").toString().toLatin1()); + if (type2.startsWith("audio/") || type2.startsWith("x-audio/")) { + episode.set_url(url); + } + // If the URL doesn't have a type, see if it's one of the obvious types + else if (type2.isEmpty() && (url.path().endsWith(".mp3", Qt::CaseInsensitive) || url.path().endsWith(".m4a", Qt::CaseInsensitive) || url.path().endsWith(".wav", Qt::CaseInsensitive))) { + episode.set_url(url); + } + Utilities::ConsumeCurrentElement(reader); + } + else if (name == "author" && lower_namespace == kItunesNamespace) { + episode.set_author(reader->readElementText()); + } + else { + Utilities::ConsumeCurrentElement(reader); + } + break; + } + + case QXmlStreamReader::EndElement: + if (!episode.publication_date().isValid()) { + episode.set_publication_date(QDateTime::currentDateTime()); + } + if (!episode.url().isEmpty()) { + ret->add_episode(episode); + } + return; + + default: + break; + } + } + +} + +bool PodcastParser::ParseOpml(QXmlStreamReader *reader, OpmlContainer *ret) const { + + if (!Utilities::ParseUntilElement(reader, "body")) { + return false; + } + + ParseOutline(reader, ret); + + // OPML files sometimes consist of a single top level container. + OpmlContainer *top = ret; + while (top->feeds.count() == 0 && top->containers.count() == 1) { + top = &top->containers[0]; + } + if (top != ret) { + // Copy the sub-container to a temporary location first. + OpmlContainer tmp = *top; + *ret = tmp; + } + + return true; + +} + +void PodcastParser::ParseOutline(QXmlStreamReader *reader, OpmlContainer *ret) const { + + while (!reader->atEnd()) { + QXmlStreamReader::TokenType type = reader->readNext(); + switch (type) { + case QXmlStreamReader::StartElement: { + const QString name = reader->name().toString(); + if (name != "outline") { + Utilities::ConsumeCurrentElement(reader); + continue; + } + + QXmlStreamAttributes attributes = reader->attributes(); + + if (attributes.value("type").toString() == "rss") { + // Parse the feed and add it to this container + Podcast podcast; + podcast.set_description(attributes.value("description").toString()); + + QString title = attributes.value("title").toString(); + if (title.isEmpty()) { + title = attributes.value("text").toString(); + } + podcast.set_title(title); + podcast.set_image_url_large(QUrl::fromEncoded(attributes.value("imageHref").toString().toLatin1())); + podcast.set_url(QUrl::fromEncoded(attributes.value("xmlUrl").toString().toLatin1())); + ret->feeds.append(podcast); + + // Consume any children and the EndElement. + Utilities::ConsumeCurrentElement(reader); + } + else { + // Create a new child container + OpmlContainer child; + + // Take the name from the fullname attribute first if it exists. + child.name = attributes.value("fullname").toString(); + if (child.name.isEmpty()) { + child.name = attributes.value("text").toString(); + } + + // Parse its contents and add it to this container + ParseOutline(reader, &child); + ret->containers.append(child); + } + + break; + } + + case QXmlStreamReader::EndElement: + return; + + default: + break; + } + } + +} diff --git a/src/podcasts/podcastparser.h b/src/podcasts/podcastparser.h new file mode 100644 index 00000000..3bb654e8 --- /dev/null +++ b/src/podcasts/podcastparser.h @@ -0,0 +1,72 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTPARSER_H +#define PODCASTPARSER_H + +#include +#include +#include +#include + +#include "podcast.h" + +class QIODevice; +class QXmlStreamReader; + +class OpmlContainer; + +// Reads XML data from a QIODevice. +// Returns either a Podcast or an OpmlContainer depending on what was inside the XML document. +class PodcastParser { + public: + PodcastParser(); + + static const char *kAtomNamespace; + static const char *kItunesNamespace; + + const QStringList &supported_mime_types() const { return supported_mime_types_; } + bool SupportsContentType(const QString &content_type) const; + + // You should check the type of the returned QVariant to see whether it contains a Podcast or an OpmlContainer. + // If the QVariant isNull then an error occurred parsing the XML. + QVariant Load(QIODevice *device, const QUrl &url) const; + + // Really quick test to see if some data might be supported. Load() might still return a null QVariant. + bool TryMagic(const QByteArray &data) const; + + private: + bool ParseRss(QXmlStreamReader *reader, Podcast *ret) const; + void ParseChannel(QXmlStreamReader *reader, Podcast *ret) const; + void ParseImage(QXmlStreamReader *reader, Podcast *ret) const; + void ParseItunesOwner(QXmlStreamReader *reader, Podcast *ret) const; + void ParseItem(QXmlStreamReader *reader, Podcast *ret) const; + + bool ParseOpml(QXmlStreamReader *reader, OpmlContainer *ret) const; + void ParseOutline(QXmlStreamReader *reader, OpmlContainer *ret) const; + + private: + QStringList supported_mime_types_; +}; + +#endif // PODCASTPARSER_H diff --git a/src/podcasts/podcastservice.cpp b/src/podcasts/podcastservice.cpp new file mode 100644 index 00000000..4e4a1c68 --- /dev/null +++ b/src/podcasts/podcastservice.cpp @@ -0,0 +1,919 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012-2013, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2013-2014, Krzysztof Sobiecki + * Copyright 2014, Simeon Bird + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include + +#include "podcastservice.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/mergedproxymodel.h" +#include "core/iconloader.h" +#include "core/standarditemiconloader.h" +//#include "podcastsmodel.h" +#include "podcastservicemodel.h" +#include "collection/collectionview.h" +#include "opmlcontainer.h" +#include "podcastbackend.h" +#include "podcastdeleter.h" +#include "podcastdownloader.h" +#include "podcastinfodialog.h" +#include "podcastupdater.h" +#include "addpodcastdialog.h" +#include "organize/organizedialog.h" +#include "organize/organizeerrordialog.h" +#include "playlist/playlistmanager.h" +#include "device/devicemanager.h" +#include "device/devicestatefiltermodel.h" +#include "device/deviceview.h" + +const char* PodcastService::kServiceName = "Podcasts"; +const char *PodcastService::kSettingsGroup = "Podcasts"; + +class PodcastSortProxyModel : public QSortFilterProxyModel { + public: + explicit PodcastSortProxyModel(QObject *parent = nullptr); + + protected: + bool lessThan(const QModelIndex &left, const QModelIndex &right) const; +}; + +PodcastService::PodcastService(Application *app, QObject *parent) + : InternetService(Song::Source_Unknown, kServiceName, QString(), QString(), SettingsDialog::Page_Appearance, app, parent), + use_pretty_covers_(true), + hide_listened_(false), + show_episodes_(0), + icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)), + backend_(app->podcast_backend()), + model_(new PodcastServiceModel(this)), + proxy_(new PodcastSortProxyModel(this)), + root_(nullptr), + organize_dialog_(new OrganizeDialog(app_->task_manager())) { + + icon_loader_->SetModel(model_); + proxy_->setSourceModel(model_); + proxy_->setDynamicSortFilter(true); + proxy_->sort(0); + + QObject::connect(backend_, &PodcastBackend::SubscriptionAdded, this, &PodcastService::SubscriptionAdded); + QObject::connect(backend_, &PodcastBackend::SubscriptionRemoved, this, &PodcastService::SubscriptionRemoved); + QObject::connect(backend_, &PodcastBackend::EpisodesAdded, this, &PodcastService::EpisodesAdded); + QObject::connect(backend_, &PodcastBackend::EpisodesUpdated, this, &PodcastService::EpisodesUpdated); + + QObject::connect(app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, this, &PodcastService::CurrentSongChanged); + QObject::connect(organize_dialog_.get(), &OrganizeDialog::FileCopied, this, &PodcastService::FileCopied); + +} + +PodcastService::~PodcastService() {} + +PodcastSortProxyModel::PodcastSortProxyModel(QObject *parent) : QSortFilterProxyModel(parent) {} + +bool PodcastSortProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { + + Q_UNUSED(left) + Q_UNUSED(right) + +#if 0 + const int left_type = left.data(InternetModel::Role_Type).toInt(); + const int right_type = right.data(InternetModel::Role_Type).toInt(); + + // The special Add Podcast item comes first + if (left_type == PodcastService::Type_AddPodcast) + return true; + else if (right_type == PodcastService::Type_AddPodcast) + return false; + + // Otherwise we only compare identical typed items. + if (left_type != right_type) + return QSortFilterProxyModel::lessThan(left, right); + + switch (left_type) { + case PodcastService::Type_Podcast: + return left.data().toString().localeAwareCompare(right.data().toString()) < 0; + + case PodcastService::Type_Episode: { + const PodcastEpisode left_episode = left.data(PodcastService::Role_Episode).value(); + const PodcastEpisode right_episode = right.data(PodcastService::Role_Episode).value(); + + return left_episode.publication_date() > right_episode.publication_date(); + } + + default: + return QSortFilterProxyModel::lessThan(left, right); + } +#endif + return false; + +} + +QStandardItem *PodcastService::CreateRootItem() { + +#if 0 + root_ = new QStandardItem(IconLoader::Load("podcast"), tr("Podcasts")); + root_->setData(true, InternetModel::Role_CanLazyLoad); + return root_; +#endif + return nullptr; + +} + +void PodcastService::CopyToDevice() { + + if (selected_episodes_.isEmpty() && explicitly_selected_podcasts_.isEmpty()) { + CopyToDevice(backend_->GetNewDownloadedEpisodes()); + } + else { + CopyToDevice(selected_episodes_, explicitly_selected_podcasts_); + } + +} + +void PodcastService::CopyToDevice(const PodcastEpisodeList &episodes_list) { + + SongList songs; + Podcast podcast; + for (const PodcastEpisode &episode : episodes_list) { + podcast = backend_->GetSubscriptionById(episode.podcast_database_id()); + songs.append(episode.ToSong(podcast)); + } + + if (songs.isEmpty()) return; + + organize_dialog_->SetDestinationModel(app_->device_manager()->connected_devices_model(), true); + organize_dialog_->SetCopy(true); + if (organize_dialog_->SetSongs(songs)) organize_dialog_->show(); + + +} + +void PodcastService::CopyToDevice(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes) { + + PodcastEpisodeList episodes; + for (const QModelIndex &idx : episode_indexes) { + PodcastEpisode episode_tmp = idx.data(Role_Episode).value(); + if (episode_tmp.downloaded()) episodes << episode_tmp; + } + + for (const QModelIndex &idx : podcast_indexes) { + for (int i = 0; i < idx.model()->rowCount(idx); ++i) { + const QModelIndex &idx2 = idx.model()->index(i, 0, idx); + PodcastEpisode episode_tmp = idx2.data(Role_Episode).value(); + if (episode_tmp.downloaded() && !episode_tmp.listened()) + episodes << episode_tmp; + } + } + SongList songs; + for (const PodcastEpisode &episode : episodes) { + Podcast podcast = backend_->GetSubscriptionById(episode.podcast_database_id()); + songs.append(episode.ToSong(podcast)); + } + + organize_dialog_->SetDestinationModel(app_->device_manager()->connected_devices_model(), true); + organize_dialog_->SetCopy(true); + if (organize_dialog_->SetSongs(songs)) organize_dialog_->show(); + +} + +void PodcastService::CancelDownload() { + CancelDownload(selected_episodes_, explicitly_selected_podcasts_); +} + +void PodcastService::CancelDownload(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes) { + + PodcastEpisodeList episodes; + for (const QModelIndex &idx : episode_indexes) { + if (!idx.isValid()) continue; + PodcastEpisode episode_tmp = idx.data(Role_Episode).value(); + episodes << episode_tmp; + } + + for (const QModelIndex &idx : podcast_indexes) { + if (!idx.isValid()) continue; + for (int i = 0; i < idx.model()->rowCount(idx); ++i) { + const QModelIndex &idx2 = idx.model()->index(i, 0, idx); + if (!idx2.isValid()) continue; + PodcastEpisode episode_tmp = idx2.data(Role_Episode).value(); + episodes << episode_tmp; + } + } + episodes = app_->podcast_downloader()->EpisodesDownloading(episodes); + app_->podcast_downloader()->cancelDownload(episodes); + +} + +void PodcastService::LazyPopulate(QStandardItem *parent) { + + Q_UNUSED(parent) +#if 0 + switch (parent->data(InternetModel::Role_Type).toInt()) { + case InternetModel::Type_Service: + PopulatePodcastList(model_->invisibleRootItem()); + model()->merged_model()->AddSubModel(parent->index(), proxy_); + break; + } +#endif + +} + +void PodcastService::PopulatePodcastList(QStandardItem *parent) { + + // Do this here since the downloader won't be created yet in the ctor. + QObject::connect(app_->podcast_downloader(), &PodcastDownloader::ProgressChanged, this, &PodcastService::DownloadProgressChanged); + + if (default_icon_.isNull()) { + default_icon_ = IconLoader::Load("podcast"); + } + + PodcastList podcasts = backend_->GetAllSubscriptions(); + for (const Podcast &podcast : podcasts) { + parent->appendRow(CreatePodcastItem(podcast)); + } + +} + +void PodcastService::ClearPodcastList(QStandardItem *parent) { + parent->removeRows(0, parent->rowCount()); +} + +void PodcastService::UpdatePodcastText(QStandardItem *item, const int unlistened_count) const { + + const Podcast podcast = item->data(Role_Podcast).value(); + + QString title = podcast.title().simplified(); + QFont font; + + if (unlistened_count > 0) { + // Add the number of new episodes after the title. + title.append(QString(" (%1)").arg(unlistened_count)); + + // Set a bold font + font.setBold(true); + } + + item->setFont(font); + item->setText(title); + +} + +void PodcastService::UpdateEpisodeText(QStandardItem *item, const PodcastDownload::State state, const int percent) { + + const PodcastEpisode episode = item->data(Role_Episode).value(); + + QString title = episode.title().simplified(); + QString tooltip; + QFont font; + QIcon icon; + + // Unlistened episodes are bold + if (!episode.listened()) { + font.setBold(true); + } + + // Downloaded episodes get an icon + if (episode.downloaded()) { + if (downloaded_icon_.isNull()) { + downloaded_icon_ = IconLoader::Load("document-save"); + } + icon = downloaded_icon_; + } + + // Queued or downloading episodes get icons, tooltips, and maybe a title. + switch (state) { + case PodcastDownload::Queued: + if (queued_icon_.isNull()) { + queued_icon_ = IconLoader::Load("user-away"); + } + icon = queued_icon_; + tooltip = tr("Download queued"); + break; + + case PodcastDownload::Downloading: + if (downloading_icon_.isNull()) { + downloading_icon_ = IconLoader::Load("go-down"); + } + icon = downloading_icon_; + tooltip = tr("Downloading (%1%)...").arg(percent); + title = QString("[ %1% ] %2").arg(QString::number(percent), episode.title()); + break; + + case PodcastDownload::Finished: + case PodcastDownload::NotDownloading: + break; + } + + item->setFont(font); + item->setText(title); + item->setIcon(icon); + +} + +void PodcastService::UpdatePodcastText(QStandardItem *item, const PodcastDownload::State state, const int percent) { + + const Podcast podcast = item->data(Role_Podcast).value(); + + QString tooltip; + QIcon icon; + + // Queued or downloading podcasts get icons, tooltips, and maybe a title. + switch (state) { + case PodcastDownload::Queued: + if (queued_icon_.isNull()) { + queued_icon_ = IconLoader::Load("user-away"); + } + icon = queued_icon_; + item->setIcon(icon); + tooltip = tr("Download queued"); + break; + + case PodcastDownload::Downloading: + if (downloading_icon_.isNull()) { + downloading_icon_ = IconLoader::Load("go-down"); + } + icon = downloading_icon_; + item->setIcon(icon); + tooltip = tr("Downloading (%1%)...").arg(percent); + break; + + case PodcastDownload::Finished: + case PodcastDownload::NotDownloading: + if (podcast.ImageUrlSmall().isValid()) { + icon_loader_->LoadIcon(podcast.ImageUrlSmall(), QUrl(), item); + } + else { + item->setIcon(default_icon_); + } + break; + } + +} + +QStandardItem *PodcastService::CreatePodcastItem(const Podcast &podcast) { + + QStandardItem *item = new QStandardItem; + + // Add the episodes in this podcast and gather aggregate stats. + int unlistened_count = 0; + qint64 number = 0; + for (const PodcastEpisode &episode : + backend_->GetEpisodes(podcast.database_id())) { + if (!episode.listened()) { + unlistened_count++; + } + + if (episode.listened() && hide_listened_) { + continue; + } + else { + item->appendRow(CreatePodcastEpisodeItem(episode)); + ++number; + } + + if ((number >= show_episodes_) && (show_episodes_ != 0)) { + break; + } + } + + item->setIcon(default_icon_); + //item->setData(Type_Podcast, InternetModel::Role_Type); + item->setData(QVariant::fromValue(podcast), Role_Podcast); + item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable); + UpdatePodcastText(item, unlistened_count); + + // Load the podcast's image if it has one + if (podcast.ImageUrlSmall().isValid()) { + icon_loader_->LoadIcon(podcast.ImageUrlSmall(), QUrl(), item); + } + + podcasts_by_database_id_[podcast.database_id()] = item; + + return item; + +} + +QStandardItem *PodcastService::CreatePodcastEpisodeItem(const PodcastEpisode &episode) { + + QStandardItem *item = new QStandardItem; + item->setText(episode.title().simplified()); + //item->setData(Type_Episode, InternetModel::Role_Type); + item->setData(QVariant::fromValue(episode), Role_Episode); + //item->setData(InternetModel::PlayBehaviour_UseSongLoader, InternetModel::Role_PlayBehaviour); + item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable); + + UpdateEpisodeText(item); + + episodes_by_database_id_[episode.database_id()] = item; + + return item; + +} + +void PodcastService::ShowContextMenu(const QPoint &global_pos) { + + if (!context_menu_) { + context_menu_ = new QMenu; + context_menu_->addAction(IconLoader::Load("list-add"), tr("Add podcast..."), this, &PodcastService::AddPodcast); + context_menu_->addAction(IconLoader::Load("view-refresh"), tr("Update all podcasts"), app_->podcast_updater(), &PodcastUpdater::UpdateAllPodcastsNow); + + context_menu_->addSeparator(); + //context_menu_->addActions(GetPlaylistActions()); + + context_menu_->addSeparator(); + update_selected_action_ = context_menu_->addAction(IconLoader::Load("view-refresh"), tr("Update this podcast"), this, &PodcastService::UpdateSelectedPodcast); + download_selected_action_ = context_menu_->addAction(IconLoader::Load("download"), "", this, &PodcastService::DownloadSelectedEpisode); + info_selected_action_ = context_menu_->addAction(IconLoader::Load("about-info"), tr("Podcast information"), this, &PodcastService::PodcastInfo); + delete_downloaded_action_ = context_menu_->addAction(IconLoader::Load("edit-delete"), tr("Delete downloaded data"), this, &PodcastService::DeleteDownloadedData); + copy_to_device_ = context_menu_->addAction(IconLoader::Load("multimedia-player-ipod-mini-blue"), tr("Copy to device..."), this, QOverload<>::of(&PodcastService::CopyToDevice)); + cancel_download_ = context_menu_->addAction(IconLoader::Load("cancel"), tr("Cancel download"), this, QOverload<>::of(&PodcastService::CancelDownload)); + remove_selected_action_ = context_menu_->addAction(IconLoader::Load("list-remove"), tr("Unsubscribe"), this, QOverload<>::of(&PodcastService::RemoveSelectedPodcast)); + + context_menu_->addSeparator(); + set_new_action_ = context_menu_->addAction(tr("Mark as new"), this, &PodcastService::SetNew); + set_listened_action_ = context_menu_->addAction(tr("Mark as listened"), this, QOverload<>::of(&PodcastService::SetListened)); + + context_menu_->addSeparator(); + context_menu_->addAction(IconLoader::Load("configure"), tr("Configure podcasts..."), this, &PodcastService::ShowConfig); + + copy_to_device_->setDisabled(app_->device_manager()->connected_devices_model()->rowCount() == 0); + connect(app_->device_manager()->connected_devices_model(), &DeviceStateFilterModel::IsEmptyChanged, copy_to_device_, &QAction::setDisabled); + } + + selected_episodes_.clear(); + selected_podcasts_.clear(); + explicitly_selected_podcasts_.clear(); + QSet podcast_ids; +#if 0 + for (const QModelIndex &index : model()->selected_indexes()) { + switch (index.data(InternetModel::Role_Type).toInt()) { + case Type_Podcast: { + const int id = index.data(Role_Podcast).value().database_id(); + if (!podcast_ids.contains(id)) { + selected_podcasts_.append(index); + explicitly_selected_podcasts_.append(index); + podcast_ids.insert(id); + } + break; + } + + case Type_Episode: { + selected_episodes_.append(index); + + // Add the parent podcast as well. + const QModelIndex parent = index.parent(); + const int id = parent.data(Role_Podcast).value().database_id(); + if (!podcast_ids.contains(id)) { + selected_podcasts_.append(parent); + podcast_ids.insert(id); + } + break; + } + } + } +#endif + + const bool episodes = !selected_episodes_.isEmpty(); + const bool podcasts = !selected_podcasts_.isEmpty(); + + update_selected_action_->setEnabled(podcasts); + remove_selected_action_->setEnabled(podcasts); + set_new_action_->setEnabled(episodes || podcasts); + set_listened_action_->setEnabled(episodes || podcasts); + cancel_download_->setEnabled(episodes || podcasts); + + if (selected_episodes_.count() == 1) { + const PodcastEpisode episode = selected_episodes_[0].data(Role_Episode).value(); + const bool downloaded = episode.downloaded(); + const bool listened = episode.listened(); + + download_selected_action_->setEnabled(!downloaded); + delete_downloaded_action_->setEnabled(downloaded); + + if (explicitly_selected_podcasts_.isEmpty()) { + set_new_action_->setEnabled(listened); + set_listened_action_->setEnabled(!listened || !episode.listened_date().isValid()); + } + } + else { + download_selected_action_->setEnabled(episodes); + delete_downloaded_action_->setEnabled(episodes); + } + + if (selected_podcasts_.count() == 1) { + if (selected_episodes_.count() == 1) { + info_selected_action_->setText(tr("Episode information")); + info_selected_action_->setEnabled(true); + } + else { + info_selected_action_->setText(tr("Podcast information")); + info_selected_action_->setEnabled(true); + } + } + else { + info_selected_action_->setText(tr("Podcast information")); + info_selected_action_->setEnabled(false); + } + + if (explicitly_selected_podcasts_.isEmpty() && selected_episodes_.isEmpty()) { + PodcastEpisodeList epis = backend_->GetNewDownloadedEpisodes(); + set_listened_action_->setEnabled(!epis.isEmpty()); + } + + if (selected_episodes_.count() > 1) { + download_selected_action_->setText( + tr("Download %n episodes", "", selected_episodes_.count())); + } + else { + download_selected_action_->setText(tr("Download this episode")); + } + + //GetAppendToPlaylistAction()->setEnabled(episodes || podcasts); + //GetReplacePlaylistAction()->setEnabled(episodes || podcasts); + //GetOpenInNewPlaylistAction()->setEnabled(episodes || podcasts); + + context_menu_->popup(global_pos); + +} + +void PodcastService::UpdateSelectedPodcast() { + + for (const QModelIndex &index : selected_podcasts_) { + app_->podcast_updater()->UpdatePodcastNow( + index.data(Role_Podcast).value()); + } + +} + +void PodcastService::RemoveSelectedPodcast() { + + for (const QModelIndex &index : selected_podcasts_) { + backend_->Unsubscribe(index.data(Role_Podcast).value()); + } + +} + +void PodcastService::ReloadSettings() { + + InitialLoadSettings(); + ClearPodcastList(model_->invisibleRootItem()); + PopulatePodcastList(model_->invisibleRootItem()); + +} + +void PodcastService::InitialLoadSettings() { + + QSettings s; + + s.beginGroup(CollectionSettingsPage::kSettingsGroup); + use_pretty_covers_ = s.value("pretty_covers", true).toBool(); + s.endGroup(); + + s.beginGroup(kSettingsGroup); + hide_listened_ = s.value("hide_listened", false).toBool(); + show_episodes_ = s.value("show_episodes", 0).toInt(); + s.endGroup(); + + // TODO(notme): reload the podcast icons that are already loaded? + +} + +void PodcastService::EnsureAddPodcastDialogCreated() { + add_podcast_dialog_.reset(new AddPodcastDialog(app_)); +} + +void PodcastService::AddPodcast() { + + EnsureAddPodcastDialogCreated(); + add_podcast_dialog_->show(); + +} + +void PodcastService::FileCopied(int database_id) { + SetListened(PodcastEpisodeList() << backend_->GetEpisodeById(database_id), true); +} + +void PodcastService::SubscriptionAdded(const Podcast &podcast) { + + // Ensure the root item is lazy loaded already + LazyLoadRoot(); + + // The podcast might already be in the list - maybe the LazyLoadRoot() above + // added it. + QStandardItem *item = podcasts_by_database_id_[podcast.database_id()]; + if (!item) { + item = CreatePodcastItem(podcast); + model_->appendRow(item); + } + + //emit ScrollToIndex(MapToMergedModel(item->index())); + +} + +void PodcastService::SubscriptionRemoved(const Podcast &podcast) { + + QStandardItem *item = podcasts_by_database_id_.take(podcast.database_id()); + if (item) { + // Remove any episode ID -> item mappings for the episodes in this podcast. + for (int i = 0; i < item->rowCount(); ++i) { + QStandardItem *episode_item = item->child(i); + const int episode_id = episode_item->data(Role_Episode).value().database_id(); + + episodes_by_database_id_.remove(episode_id); + } + + // Remove this episode's row + model_->removeRow(item->row()); + } + +} + +void PodcastService::EpisodesAdded(const PodcastEpisodeList &episodes) { + + QSet seen_podcast_ids; + + for (const PodcastEpisode &episode : episodes) { + const int database_id = episode.podcast_database_id(); + QStandardItem *parent = podcasts_by_database_id_[database_id]; + if (!parent) continue; + + parent->appendRow(CreatePodcastEpisodeItem(episode)); + if (!seen_podcast_ids.contains(database_id)) { + // Update the unlistened count text once for each podcast + int unlistened_count = 0; + for (const PodcastEpisode &i : backend_->GetEpisodes(database_id)) { + if (!i.listened()) { + ++unlistened_count; + } + } + + UpdatePodcastText(parent, unlistened_count); + seen_podcast_ids.insert(database_id); + } + const Podcast podcast = parent->data(Role_Podcast).value(); + ReloadPodcast(podcast); + } + +} + +void PodcastService::EpisodesUpdated(const PodcastEpisodeList &episodes) { + + QSet seen_podcast_ids; + QMap podcasts_map; + + for (const PodcastEpisode &episode : episodes) { + const int podcast_database_id = episode.podcast_database_id(); + QStandardItem *item = episodes_by_database_id_[episode.database_id()]; + QStandardItem *parent = podcasts_by_database_id_[podcast_database_id]; + if (!item || !parent) continue; + // Update the episode data on the item, and update the item's text. + item->setData(QVariant::fromValue(episode), Role_Episode); + UpdateEpisodeText(item); + + // Update the parent podcast's text too. + if (!seen_podcast_ids.contains(podcast_database_id)) { + // Update the unlistened count text once for each podcast + int unlistened_count = 0; + for (const PodcastEpisode &i : backend_->GetEpisodes(podcast_database_id)) { + if (!i.listened()) { + ++unlistened_count; + } + } + + UpdatePodcastText(parent, unlistened_count); + seen_podcast_ids.insert(podcast_database_id); + } + const Podcast podcast = parent->data(Role_Podcast).value(); + podcasts_map[podcast.database_id()] = podcast; + } + + QList podcast_values = podcasts_map.values(); + for (const Podcast &podcast_tmp : podcast_values) { + ReloadPodcast(podcast_tmp); + } + +} + +void PodcastService::DownloadSelectedEpisode() { + + for (const QModelIndex &idx : selected_episodes_) { + app_->podcast_downloader()->DownloadEpisode(idx.data(Role_Episode).value()); + } + +} + +void PodcastService::PodcastInfo() { + + if (selected_podcasts_.isEmpty()) { + // Should never happen. + return; + } + const Podcast podcast = selected_podcasts_[0].data(Role_Podcast).value(); + podcast_info_dialog_.reset(new PodcastInfoDialog(app_)); + + if (selected_episodes_.count() == 1) { + const PodcastEpisode episode = selected_episodes_[0].data(Role_Episode).value(); + podcast_info_dialog_->ShowEpisode(episode, podcast); + } + else { + podcast_info_dialog_->ShowPodcast(podcast); + } + +} + +void PodcastService::DeleteDownloadedData() { + + for (const QModelIndex &idx : selected_episodes_) { + app_->podcast_deleter()->DeleteEpisode(idx.data(Role_Episode).value()); + } + +} + +void PodcastService::DownloadProgressChanged(const PodcastEpisode &episode, const PodcastDownload::State state, const int percent) { + + QStandardItem *item = episodes_by_database_id_[episode.database_id()]; + QStandardItem *item2 = podcasts_by_database_id_[episode.podcast_database_id()]; + if (!item || !item2) return; + + UpdateEpisodeText(item, state, percent); + UpdatePodcastText(item2, state, percent); + +} + +void PodcastService::ShowConfig() { + //app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Podcasts); +} + +void PodcastService::CurrentSongChanged(const Song &metadata) { + + // This does two db queries, and we are called on every song change, so run this off the main thread. +#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) + (void)QtConcurrent::run(&PodcastService::UpdatePodcastListenedStateAsync, this, metadata); +#else + (void)QtConcurrent::run(this, &PodcastService::UpdatePodcastListenedStateAsync, metadata); +#endif +} + +void PodcastService::UpdatePodcastListenedStateAsync(const Song &metadata) { + + // Check whether this song is one of our podcast episodes. + PodcastEpisode episode = backend_->GetEpisodeByUrlOrLocalUrl(metadata.url()); + if (!episode.is_valid()) return; + + // Mark it as listened if it's not already + if (!episode.listened() || !episode.listened_date().isValid()) { + episode.set_listened(true); + episode.set_listened_date(QDateTime::currentDateTime()); + backend_->UpdateEpisodes(PodcastEpisodeList() << episode); + } + +} + +void PodcastService::SetNew() { + SetListened(selected_episodes_, explicitly_selected_podcasts_, false); +} + +void PodcastService::SetListened() { + + if (selected_episodes_.isEmpty() && explicitly_selected_podcasts_.isEmpty()) { + SetListened(backend_->GetNewDownloadedEpisodes(), true); + } + else { + SetListened(selected_episodes_, explicitly_selected_podcasts_, true); + } + +} + +void PodcastService::SetListened(const PodcastEpisodeList &episodes_list, const bool listened) { + + PodcastEpisodeList episodes; + QDateTime current_date_time = QDateTime::currentDateTime(); + for (PodcastEpisode episode : episodes_list) { + episode.set_listened(listened); + if (listened) { + episode.set_listened_date(current_date_time); + } + episodes << episode; + } + + backend_->UpdateEpisodes(episodes); + +} + +void PodcastService::SetListened(const QModelIndexList &episode_indexes, const QModelIndexList& podcast_indexes, const bool listened) { + + PodcastEpisodeList episodes; + + // Get all the episodes from the indexes. + for (const QModelIndex& index : episode_indexes) { + episodes << index.data(Role_Episode).value(); + } + + for (const QModelIndex& podcast : podcast_indexes) { + for (int i = 0; i < podcast.model()->rowCount(podcast); ++i) { + const QModelIndex& index = podcast.model()->index(i, 0, podcast); + episodes << index.data(Role_Episode).value(); + } + } + + // Update each one with the new state and maybe the listened time. + QDateTime current_date_time = QDateTime::currentDateTime(); + for (int i = 0; i < episodes.count(); ++i) { + PodcastEpisode *episode = &episodes[i]; + episode->set_listened(listened); + if (listened) { + episode->set_listened_date(current_date_time); + } + } + + backend_->UpdateEpisodes(episodes); + +} + +QModelIndex PodcastService::MapToMergedModel(const QModelIndex &idx) const { + + Q_UNUSED(idx) + //return model()->merged_model()->mapFromSource(proxy_->mapFromSource(index)); + return QModelIndex(); + +} + +void PodcastService::LazyLoadRoot() { + +#if 0 + if (root_->data(InternetModel::Role_CanLazyLoad).toBool()) { + root_->setData(false, InternetModel::Role_CanLazyLoad); + LazyPopulate(root_); + } +#endif + +} + +void PodcastService::SubscribeAndShow(const QVariant &podcast_or_opml) { + + if (podcast_or_opml.canConvert()) { + Podcast podcast(podcast_or_opml.value()); + backend_->Subscribe(&podcast); + + // Lazy load the root item if it hasn't been already + LazyLoadRoot(); + + QStandardItem *item = podcasts_by_database_id_[podcast.database_id()]; + if (item) { + // There will be an item already if this podcast was already there, otherwise it'll be scrolled to when the item is created. + //emit ScrollToIndex(MapToMergedModel(item->index())); + } + } + else if (podcast_or_opml.canConvert()) { + EnsureAddPodcastDialogCreated(); + + add_podcast_dialog_->ShowWithOpml(podcast_or_opml.value()); + } + +} + +void PodcastService::ReloadPodcast(const Podcast &podcast) { + + if (!(hide_listened_ || (show_episodes_ > 0))) { + return; + } + QStandardItem *item = podcasts_by_database_id_[podcast.database_id()]; + + model_->invisibleRootItem()->removeRow(item->row()); + model_->invisibleRootItem()->appendRow(CreatePodcastItem(podcast)); + +} diff --git a/src/podcasts/podcastservice.h b/src/podcasts/podcastservice.h new file mode 100644 index 00000000..992f3069 --- /dev/null +++ b/src/podcasts/podcastservice.h @@ -0,0 +1,178 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012-2013, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2013-2014, Krzysztof Sobiecki + * Copyright 2014, Simeon Bird + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTSERVICE_H +#define PODCASTSERVICE_H + +#include + +#include +#include +#include + +//#include "internet/internetmodel.h" +#include "internet/internetservice.h" +#include "podcastdeleter.h" +#include "podcastdownloader.h" + +class QMenu; +class QAction; + +class AddPodcastDialog; +class PodcastInfoDialog; +class OrganizeDialog; +class Podcast; +class PodcastBackend; +class PodcastEpisode; +class StandardItemIconLoader; + +class QStandardItemModel; +class QStandardItem; +class QSortFilterProxyModel; + +class PodcastService : public InternetService { + Q_OBJECT + + public: + PodcastService(Application *app, QObject *parent); + ~PodcastService(); + + static const char *kServiceName; + static const char *kSettingsGroup; + + enum Type { + Type_AddPodcast = 0, + Type_Podcast, + Type_Episode + }; + + enum Role { + Role_Podcast = 0, + Role_Episode + }; + + QStandardItem *CreateRootItem(); + void LazyPopulate(QStandardItem *parent); + bool has_initial_load_settings() const { return true; } + void ShowContextMenu(const QPoint &global_pos); + void ReloadSettings(); + void InitialLoadSettings(); + // Called by SongLoader when the user adds a Podcast URL directly. + // Adds a subscription to the podcast and displays it in the UI. + // If the QVariant contains an OPML file then this displays it in the Add Podcast dialog. + void SubscribeAndShow(const QVariant &podcast_or_opml); + + public slots: + void AddPodcast(); + void FileCopied(const int database_id); + + private slots: + void UpdateSelectedPodcast(); + void ReloadPodcast(const Podcast &podcast); + void RemoveSelectedPodcast(); + void DownloadSelectedEpisode(); + void PodcastInfo(); + void DeleteDownloadedData(); + void SetNew(); + void SetListened(); + void ShowConfig(); + + void SubscriptionAdded(const Podcast &podcast); + void SubscriptionRemoved(const Podcast &podcast); + void EpisodesAdded(const PodcastEpisodeList &episodes); + void EpisodesUpdated(const PodcastEpisodeList &episodes); + + void DownloadProgressChanged(const PodcastEpisode &episode, PodcastDownload::State state, int percent); + + void CurrentSongChanged(const Song &metadata); + + void CopyToDevice(); + void CopyToDevice(const PodcastEpisodeList &episodes_list); + void CopyToDevice(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes); + void CancelDownload(); + void CancelDownload(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes); + + private: + void EnsureAddPodcastDialogCreated(); + + void UpdatePodcastListenedStateAsync(const Song &metadata); + void PopulatePodcastList(QStandardItem *parent); + void ClearPodcastList(QStandardItem *parent); + void UpdatePodcastText(QStandardItem *item, const int unlistened_count) const; + void UpdateEpisodeText(QStandardItem *item, const PodcastDownload::State state = PodcastDownload::NotDownloading, const int percent = 0); + void UpdatePodcastText(QStandardItem *item, const PodcastDownload::State state = PodcastDownload::NotDownloading, const int percent = 0); + + QStandardItem *CreatePodcastItem(const Podcast &podcast); + QStandardItem *CreatePodcastEpisodeItem(const PodcastEpisode &episode); + + QModelIndex MapToMergedModel(const QModelIndex &idx) const; + + void SetListened(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes, const bool listened); + void SetListened(const PodcastEpisodeList &episodes_list, bool listened); + + void LazyLoadRoot(); + + private: + bool use_pretty_covers_; + bool hide_listened_; + qint64 show_episodes_; + StandardItemIconLoader *icon_loader_; + + // The podcast icon + QIcon default_icon_; + + // Episodes get different icons depending on their state + QIcon queued_icon_; + QIcon downloading_icon_; + QIcon downloaded_icon_; + + PodcastBackend *backend_; + QStandardItemModel *model_; + QSortFilterProxyModel *proxy_; + + QMenu *context_menu_; + QAction *update_selected_action_; + QAction *remove_selected_action_; + QAction *download_selected_action_; + QAction *info_selected_action_; + QAction *delete_downloaded_action_; + QAction *set_new_action_; + QAction *set_listened_action_; + QAction *copy_to_device_; + QAction *cancel_download_; + QStandardItem *root_; + std::unique_ptr organize_dialog_; + + QModelIndexList explicitly_selected_podcasts_; + QModelIndexList selected_podcasts_; + QModelIndexList selected_episodes_; + + QMap podcasts_by_database_id_; + QMap episodes_by_database_id_; + + std::unique_ptr add_podcast_dialog_; + std::unique_ptr podcast_info_dialog_; +}; + +#endif // PODCASTSERVICE_H diff --git a/src/podcasts/podcastservicemodel.cpp b/src/podcasts/podcastservicemodel.cpp new file mode 100644 index 00000000..209747a9 --- /dev/null +++ b/src/podcasts/podcastservicemodel.cpp @@ -0,0 +1,101 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include + +#include "podcastservicemodel.h" + +#include "podcastservice.h" +#include "playlist/songmimedata.h" + +PodcastServiceModel::PodcastServiceModel(QObject* parent) : QStandardItemModel(parent) {} + +QMimeData* PodcastServiceModel::mimeData(const QModelIndexList &indexes) const { + SongMimeData *data = new SongMimeData; + QList urls; +#if 0 + for (const QModelIndex& index : indexes) { + switch (index.data(InternetModel::Role_Type).toInt()) { + case PodcastService::Type_Episode: + MimeDataForEpisode(index, data, &urls); + break; + + case PodcastService::Type_Podcast: + MimeDataForPodcast(index, data, &urls); + break; + } + } +#endif + + data->setUrls(urls); + return data; + +} + +void PodcastServiceModel::MimeDataForEpisode(const QModelIndex &idx, SongMimeData *data, QList* urls) const { + + QVariant episode_variant = idx.data(PodcastService::Role_Episode); + if (!episode_variant.isValid()) return; + + PodcastEpisode episode(episode_variant.value()); + + // Get the podcast from the index's parent + Podcast podcast; + QVariant podcast_variant = idx.parent().data(PodcastService::Role_Podcast); + if (podcast_variant.isValid()) { + podcast = podcast_variant.value(); + } + + Song song = episode.ToSong(podcast); + + data->songs << song; + *urls << song.url(); + +} + +void PodcastServiceModel::MimeDataForPodcast(const QModelIndex &idx, SongMimeData *data, QList *urls) const { + + // Get the podcast + Podcast podcast; + QVariant podcast_variant = idx.data(PodcastService::Role_Podcast); + if (podcast_variant.isValid()) { + podcast = podcast_variant.value(); + } + + // Add each child episode + const int children = idx.model()->rowCount(idx); + for (int i = 0; i < children; ++i) { + QVariant episode_variant = idx.model()->index(i, 0, idx).data(PodcastService::Role_Episode); + if (!episode_variant.isValid()) continue; + + PodcastEpisode episode(episode_variant.value()); + Song song = episode.ToSong(podcast); + + data->songs << song; + *urls << song.url(); + } + +} diff --git a/src/podcasts/podcastservicemodel.h b/src/podcasts/podcastservicemodel.h new file mode 100644 index 00000000..204d22bd --- /dev/null +++ b/src/podcasts/podcastservicemodel.h @@ -0,0 +1,46 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTSERVICEMODEL_H +#define PODCASTSERVICEMODEL_H + +#include +#include +#include + +class SongMimeData; + +class PodcastServiceModel : public QStandardItemModel { + Q_OBJECT + + public: + explicit PodcastServiceModel(QObject *parent = nullptr); + + QMimeData* mimeData(const QModelIndexList &indexes) const; + + private: + void MimeDataForPodcast(const QModelIndex &idx, SongMimeData* data, QList *urls) const; + void MimeDataForEpisode(const QModelIndex &idx, SongMimeData* data, QList *urls) const; +}; + +#endif // PODCASTSERVICEMODEL_H diff --git a/src/podcasts/podcastupdater.cpp b/src/podcasts/podcastupdater.cpp new file mode 100644 index 00000000..68afaa9f --- /dev/null +++ b/src/podcasts/podcastupdater.cpp @@ -0,0 +1,194 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include "podcastupdater.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/application.h" +#include "core/logging.h" +#include "core/timeconstants.h" +#include "podcastbackend.h" +#include "podcasturlloader.h" + +const char *PodcastUpdater::kSettingsGroup = "Podcasts"; + +PodcastUpdater::PodcastUpdater(Application *app, QObject *parent) + : QObject(parent), + app_(app), + update_interval_secs_(0), + update_timer_(new QTimer(this)), + loader_(new PodcastUrlLoader(this)), + pending_replies_(0) { + + update_timer_->setSingleShot(true); + + QObject::connect(app_, &Application::SettingsChanged, this, &PodcastUpdater::ReloadSettings); + QObject::connect(update_timer_, &QTimer::timeout, this, &PodcastUpdater::UpdateAllPodcastsNow); + QObject::connect(app_->podcast_backend(), &PodcastBackend::SubscriptionAdded, this, &PodcastUpdater::SubscriptionAdded); + + ReloadSettings(); + +} + +void PodcastUpdater::ReloadSettings() { + + QSettings s; + s.beginGroup(kSettingsGroup); + + last_full_update_ = s.value("last_full_update").toDateTime(); + update_interval_secs_ = s.value("update_interval_secs").toInt(); + + s.endGroup(); + + RestartTimer(); + +} + +void PodcastUpdater::SaveSettings() { + + QSettings s; + s.beginGroup(kSettingsGroup); + s.setValue("last_full_update", last_full_update_); + s.endGroup(); + +} + +void PodcastUpdater::RestartTimer() { + + // Stop any existing timer + update_timer_->stop(); + + if (pending_replies_ > 0) { + // We're still waiting for replies from the last update - don't do anything. + return; + } + + if (update_interval_secs_ > 0) { + if (!last_full_update_.isValid()) { + // Updates are enabled and we've never updated before. Do it now. + qLog(Info) << "Updating podcasts for the first time"; + UpdateAllPodcastsNow(); + } + else { + const QDateTime next_update = last_full_update_.addSecs(update_interval_secs_); + const int secs_until_next_update = QDateTime::currentDateTime().secsTo(next_update); + + if (secs_until_next_update < 0) { + qLog(Info) << "Updating podcasts" << (-secs_until_next_update) << "seconds late"; + UpdateAllPodcastsNow(); + } + else { + qLog(Info) << "Updating podcasts at" << next_update << "(in" << secs_until_next_update << "seconds)"; + update_timer_->start(secs_until_next_update * kMsecPerSec); + } + } + } + +} + +void PodcastUpdater::SubscriptionAdded(const Podcast& podcast) { + + // Only update a new podcast immediately if it doesn't have an episode list. + // We assume that the episode list has already been fetched recently otherwise. + if (podcast.episodes().isEmpty()) { + UpdatePodcastNow(podcast); + } + +} + +void PodcastUpdater::UpdatePodcastNow(const Podcast& podcast) { + + PodcastUrlLoaderReply *reply = loader_->Load(podcast.url()); + QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply, podcast]() { PodcastLoaded(reply, podcast, false); }); + +} + +void PodcastUpdater::UpdateAllPodcastsNow() { + + PodcastList podcasts = app_->podcast_backend()->GetAllSubscriptions(); + for (const Podcast &podcast : podcasts) { + PodcastUrlLoaderReply *reply = loader_->Load(podcast.url()); + QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply, podcast]() { PodcastLoaded(reply, podcast, true); }); + + ++pending_replies_; + } + +} + +void PodcastUpdater::PodcastLoaded(PodcastUrlLoaderReply *reply, const Podcast& podcast, bool one_of_many) { + + reply->deleteLater(); + + if (one_of_many) { + --pending_replies_; + if (pending_replies_ == 0) { + // This was the last reply we were waiting for. Save this time as being + // the last successful update and restart the timer. + last_full_update_ = QDateTime::currentDateTime(); + SaveSettings(); + RestartTimer(); + } + } + + if (!reply->is_success()) { + qLog(Warning) << "Error fetching podcast at" << podcast.url() << ":" << reply->error_text(); + return; + } + + if (reply->result_type() != PodcastUrlLoaderReply::Type_Podcast) { + qLog(Warning) << "The URL" << podcast.url() << "no longer contains a podcast"; + return; + } + + // Get the episode URLs we had for this podcast already. + QSet existing_urls; + for (const PodcastEpisode &episode : + app_->podcast_backend()->GetEpisodes(podcast.database_id())) { + existing_urls.insert(episode.url()); + } + + // Add any new episodes + PodcastEpisodeList new_episodes; + PodcastList reply_podcasts = reply->podcast_results(); + for (const Podcast &reply_podcast : reply_podcasts) { + PodcastEpisodeList episodes = reply_podcast.episodes(); + for (const PodcastEpisode &episode : episodes) { + if (!existing_urls.contains(episode.url())) { + PodcastEpisode episode_copy(episode); + episode_copy.set_podcast_database_id(podcast.database_id()); + new_episodes.append(episode_copy); + } + } + } + + app_->podcast_backend()->AddEpisodes(&new_episodes); + qLog(Info) << "Added" << new_episodes.count() << "new episodes for" << podcast.url(); + +} diff --git a/src/podcasts/podcastupdater.h b/src/podcasts/podcastupdater.h new file mode 100644 index 00000000..d48522b4 --- /dev/null +++ b/src/podcasts/podcastupdater.h @@ -0,0 +1,71 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTUPDATER_H +#define PODCASTUPDATER_H + +#include +#include + +class Application; +class Podcast; +class PodcastUrlLoader; +class PodcastUrlLoaderReply; + +class QTimer; + +// Responsible for updating podcasts when they're first subscribed to, and then updating them at regular intervals afterwards. +class PodcastUpdater : public QObject { + Q_OBJECT + + public: + explicit PodcastUpdater(Application *app, QObject *parent = nullptr); + + public slots: + void UpdateAllPodcastsNow(); + void UpdatePodcastNow(const Podcast &podcast); + + private slots: + void ReloadSettings(); + + void SubscriptionAdded(const Podcast &podcast); + void PodcastLoaded(PodcastUrlLoaderReply *reply, const Podcast &podcast, const bool one_of_many); + + private: + void RestartTimer(); + void SaveSettings(); + + private: + static const char *kSettingsGroup; + + Application *app_; + + QDateTime last_full_update_; + int update_interval_secs_; + + QTimer *update_timer_; + PodcastUrlLoader *loader_; + int pending_replies_; +}; + +#endif // PODCASTUPDATER_H diff --git a/src/podcasts/podcasturlloader.cpp b/src/podcasts/podcasturlloader.cpp new file mode 100644 index 00000000..eb5bf8f7 --- /dev/null +++ b/src/podcasts/podcasturlloader.cpp @@ -0,0 +1,250 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#include +#include +#include +#include +#include + +#include "core/logging.h" +#include "core/networkaccessmanager.h" +#include "core/utilities.h" +#include "podcasturlloader.h" +#include "podcastparser.h" + +const int PodcastUrlLoader::kMaxRedirects = 5; + +PodcastUrlLoader::PodcastUrlLoader(QObject* parent) + : QObject(parent), + network_(new NetworkAccessManager(this)), + parser_(new PodcastParser), + html_link_re_(""), + html_link_rel_re_("rel\\s*=\\s*['\"]?\\s*alternate"), + html_link_type_re_("type\\s*=\\s*['\"]?([^'\" ]+)"), + html_link_href_re_("href\\s*=\\s*['\"]?([^'\" ]+)") { + + //html_link_re_.setMinimal(true); + //html_link_re_.setCaseSensitivity(Qt::CaseInsensitive); + +} + +PodcastUrlLoader::~PodcastUrlLoader() { delete parser_; } + +QUrl PodcastUrlLoader::FixPodcastUrl(const QString& url_text) { + + QString url_text_copy(url_text.trimmed()); + + // Thanks gpodder! + QuickPrefixList quick_prefixes = QuickPrefixList() + << QuickPrefix("fb:", "http://feeds.feedburner.com/%1") + << QuickPrefix("yt:", "https://www.youtube.com/rss/user/%1/videos.rss") + << QuickPrefix("sc:", "https://soundcloud.com/%1") + << QuickPrefix("fm4od:", "http://onapp1.orf.at/webcam/fm4/fod/%1.xspf") + << QuickPrefix("ytpl:", "https://gdata.youtube.com/feeds/api/playlists/%1"); + + // Check if it matches one of the quick prefixes. + for (QuickPrefixList::const_iterator it = quick_prefixes.constBegin(); it != quick_prefixes.constEnd(); ++it) { + if (url_text_copy.startsWith(it->first)) { + url_text_copy = it->second.arg(url_text_copy.mid(it->first.length())); + } + } + + if (!url_text_copy.contains("://")) { + url_text_copy.prepend("http://"); + } + + return FixPodcastUrl(QUrl(url_text_copy)); + +} + +QUrl PodcastUrlLoader::FixPodcastUrl(const QUrl& url_orig) { + + QUrl url(url_orig); + QUrlQuery url_query(url); + + // Replace schemes + if (url.scheme().isEmpty() || url.scheme() == "feed" || url.scheme() == "itpc" || url.scheme() == "itms") { + url.setScheme("http"); + } + else if (url.scheme() == "zune" && url.host() == "subscribe" && + !url_query.queryItems().isEmpty()) { + url = QUrl(url_query.queryItems()[0].second); + } + + return url; + +} + +PodcastUrlLoaderReply* PodcastUrlLoader::Load(const QString& url_text) { + return Load(FixPodcastUrl(url_text)); +} + +PodcastUrlLoaderReply* PodcastUrlLoader::Load(const QUrl& url) { + + // Create a reply + PodcastUrlLoaderReply* reply = new PodcastUrlLoaderReply(url, this); + + // Create a state object to track this request + RequestState* state = new RequestState; + state->redirects_remaining_ = kMaxRedirects + 1; + state->reply_ = reply; + + // Start the first request + NextRequest(url, state); + + return reply; + +} + +void PodcastUrlLoader::SendErrorAndDelete(const QString& error_text, RequestState* state) { + + state->reply_->SetFinished(error_text); + delete state; + +} + +void PodcastUrlLoader::NextRequest(const QUrl& url, RequestState* state) { + + // Stop the request if there have been too many redirects already. + if (state->redirects_remaining_-- == 0) { + SendErrorAndDelete(tr("Too many redirects"), state); + return; + } + + qLog(Debug) << "Loading URL" << url; + + QNetworkRequest req(url); + req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork); + QNetworkReply* network_reply = network_->get(req); + + QObject::connect(network_reply, &QNetworkReply::finished, this, [this, state, network_reply]() { RequestFinished(state, network_reply); }); + +} + +void PodcastUrlLoader::RequestFinished(RequestState* state, QNetworkReply* reply) { + + reply->deleteLater(); + + if (reply->attribute(QNetworkRequest::RedirectionTargetAttribute).isValid()) { + const QUrl next_url = reply->url().resolved(reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl()); + + NextRequest(next_url, state); + return; + } + + // Check for errors. + if (reply->error() != QNetworkReply::NoError) { + SendErrorAndDelete(reply->errorString(), state); + return; + } + + const QVariant http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + if (http_status.isValid() && http_status.toInt() != 200) { + SendErrorAndDelete( + QString("HTTP %1: %2").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) .toString(), reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString()), + state); + return; + } + + // Check the mime type. + const QString content_type = reply->header(QNetworkRequest::ContentTypeHeader).toString(); + if (parser_->SupportsContentType(content_type)) { + const QVariant ret = parser_->Load(reply, reply->url()); + + if (ret.canConvert()) { + state->reply_->SetFinished(PodcastList() << ret.value()); + } + else if (ret.canConvert()) { + state->reply_->SetFinished(ret.value()); + } + else { + SendErrorAndDelete(tr("Failed to parse the XML for this RSS feed"), + state); + return; + } + + delete state; + return; + } + else if (content_type.contains("text/html")) { + // I don't want a full HTML parser here, so do this the dirty way. + const QString page_text = QString::fromUtf8(reply->readAll()); + //int pos = 0; +#if 0 + while ((pos = html_link_re_.indexIn(page_text, pos)) != -1) { + const QString link = html_link_re_.cap(1).toLower(); + pos += html_link_re_.matchedLength(); + + if (html_link_rel_re_.indexIn(link) == -1 || + html_link_type_re_.indexIn(link) == -1 || + html_link_href_re_.indexIn(link) == -1) { + continue; + } + + const QString link_type = html_link_type_re_.cap(1); + const QString href = Utilities::DecodeHtmlEntities(html_link_href_re_.cap(1)); + + if (parser_->supported_mime_types().contains(link_type)) { + NextRequest(QUrl(href), state); + return; + } + } +#endif + + SendErrorAndDelete(tr("HTML page did not contain any RSS feeds"), state); + } + else { + SendErrorAndDelete(tr("Unknown content-type") + ": " + content_type, state); + } + +} + +PodcastUrlLoaderReply::PodcastUrlLoaderReply(const QUrl& url, QObject* parent) + : QObject(parent), url_(url), finished_(false) {} + +void PodcastUrlLoaderReply::SetFinished(const PodcastList& results) { + + result_type_ = Type_Podcast; + podcast_results_ = results; + finished_ = true; + emit Finished(true); + +} + +void PodcastUrlLoaderReply::SetFinished(const OpmlContainer& results) { + + result_type_ = Type_Opml; + opml_results_ = results; + finished_ = true; + emit Finished(true); + +} + +void PodcastUrlLoaderReply::SetFinished(const QString& error_text) { + + error_text_ = error_text; + finished_ = true; + emit Finished(false); + +} diff --git a/src/podcasts/podcasturlloader.h b/src/podcasts/podcasturlloader.h new file mode 100644 index 00000000..0f702f34 --- /dev/null +++ b/src/podcasts/podcasturlloader.h @@ -0,0 +1,119 @@ +/* + * Strawberry Music Player + * This file was part of Clementine. + * Copyright 2012, David Sansome + * Copyright 2012, 2014, John Maguire + * Copyright 2014, Krzysztof Sobiecki + * Copyright 2019-2021, Jonas Kvinge + * + * 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 . + * + */ + +#ifndef PODCASTURLLOADER_H +#define PODCASTURLLOADER_H + +#include +#include + +#include "opmlcontainer.h" +#include "podcast.h" + +class PodcastParser; + +class QNetworkAccessManager; +class QNetworkReply; + +class PodcastUrlLoaderReply : public QObject { + Q_OBJECT + + public: + PodcastUrlLoaderReply(const QUrl& url, QObject* parent); + + enum ResultType { Type_Podcast, + Type_Opml }; + + const QUrl& url() const { return url_; } + bool is_finished() const { return finished_; } + bool is_success() const { return error_text_.isEmpty(); } + const QString& error_text() const { return error_text_; } + + ResultType result_type() const { return result_type_; } + const PodcastList& podcast_results() const { return podcast_results_; } + const OpmlContainer& opml_results() const { return opml_results_; } + + void SetFinished(const QString& error_text); + void SetFinished(const PodcastList& results); + void SetFinished(const OpmlContainer& results); + + signals: + void Finished(bool success); + + private: + QUrl url_; + bool finished_; + QString error_text_; + + ResultType result_type_; + PodcastList podcast_results_; + OpmlContainer opml_results_; +}; + +class PodcastUrlLoader : public QObject { + Q_OBJECT + + public: + explicit PodcastUrlLoader(QObject* parent = nullptr); + ~PodcastUrlLoader(); + + static const int kMaxRedirects; + + PodcastUrlLoaderReply* Load(const QString& url_text); + PodcastUrlLoaderReply* Load(const QUrl& url); + + // Both the FixPodcastUrl functions replace common podcatcher URL schemes + // like itpc:// or zune:// with their http:// equivalents. The QString + // overload also cleans up user-entered text a bit - stripping whitespace and + // applying shortcuts like sc:tag. + static QUrl FixPodcastUrl(const QString& url_text); + static QUrl FixPodcastUrl(const QUrl& url); + + private: + struct RequestState { + int redirects_remaining_; + PodcastUrlLoaderReply* reply_; + }; + + typedef QPair QuickPrefix; + typedef QList QuickPrefixList; + + private slots: + void RequestFinished(RequestState* state, QNetworkReply* reply); + + private: + void SendErrorAndDelete(const QString& error_text, RequestState* state); + void NextRequest(const QUrl& url, RequestState* state); + + private: + QNetworkAccessManager* network_; + PodcastParser* parser_; + + QRegularExpression html_link_re_; + QRegularExpression whitespace_re_; + QRegularExpression html_link_rel_re_; + QRegularExpression html_link_type_re_; + QRegularExpression html_link_href_re_; +}; + +#endif // PODCASTURLLOADER_H diff --git a/src/settings/podcastsettingspage.cpp b/src/settings/podcastsettingspage.cpp new file mode 100644 index 00000000..a7c3701c --- /dev/null +++ b/src/settings/podcastsettingspage.cpp @@ -0,0 +1,146 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + Copyright 2014, Krzysztof Sobiecki + Copyright 2014, John Maguire + + Clementine 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. + + Clementine 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 Clementine. If not, see . +*/ + +#include "podcastsettingspage.h" + +#include +#include + +#include "core/application.h" +#include "core/timeconstants.h" +#include "core/iconloader.h" +#include "collection/collectiondirectorymodel.h" +#include "collection/collectionmodel.h" +#include "podcasts/gpoddersync.h" +#include "podcasts/podcastdownloader.h" +#include "settingsdialog.h" +#include "ui_podcastsettingspage.h" + +const char* PodcastSettingsPage::kSettingsGroup = "Podcasts"; + +PodcastSettingsPage::PodcastSettingsPage(SettingsDialog* dialog) + : SettingsPage(dialog), ui_(new Ui_PodcastSettingsPage) { + ui_->setupUi(this); + setWindowIcon(IconLoader::Load("podcast")); + + connect(ui_->login, SIGNAL(clicked()), SLOT(LoginClicked())); + connect(ui_->login_state, SIGNAL(LoginClicked()), SLOT(LoginClicked())); + connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked())); + connect(ui_->download_dir_browse, SIGNAL(clicked()), + SLOT(DownloadDirBrowse())); + + GPodderSync* gsync = dialog->app()->gpodder_sync(); + connect(gsync, SIGNAL(LoginSuccess()), SLOT(GpodderLoginSuccess())); + connect(gsync, SIGNAL(LoginFailure(const QString&)), SLOT(GpodderLoginFailure(const QString&))); + + ui_->login_state->AddCredentialField(ui_->username); + ui_->login_state->AddCredentialField(ui_->device_name); + ui_->login_state->AddCredentialField(ui_->password); + ui_->login_state->AddCredentialGroup(ui_->login_group); + + ui_->check_interval->setItemData(0, 0); // manually + ui_->check_interval->setItemData(1, 10 * 60); // 10 minutes + ui_->check_interval->setItemData(2, 20 * 60); // 20 minutes + ui_->check_interval->setItemData(3, 30 * 60); // 30 minutes + ui_->check_interval->setItemData(4, 60 * 60); // 1 hour + ui_->check_interval->setItemData(5, 2 * 60 * 60); // 2 hours + ui_->check_interval->setItemData(6, 6 * 60 * 60); // 6 hours + ui_->check_interval->setItemData(7, 12 * 60 * 60); // 12 hours +} + +PodcastSettingsPage::~PodcastSettingsPage() { delete ui_; } + +void PodcastSettingsPage::Load() { + QSettings s; + s.beginGroup(kSettingsGroup); + + const int update_interval = s.value("update_interval_secs", 0).toInt(); + ui_->check_interval->setCurrentIndex( + ui_->check_interval->findData(update_interval)); + + const QString default_download_dir = + dialog()->app()->podcast_downloader()->DefaultDownloadDir(); + ui_->download_dir->setText(QDir::toNativeSeparators( + s.value("download_dir", default_download_dir).toString())); + + ui_->auto_download->setChecked(s.value("auto_download", false).toBool()); + ui_->hide_listened->setChecked(s.value("hide_listened", false).toBool()); + ui_->delete_after->setValue(s.value("delete_after", 0).toInt() / kSecsPerDay); + ui_->show_episodes->setValue(s.value("show_episodes", 0).toInt()); + ui_->username->setText(s.value("gpodder_username").toString()); + ui_->device_name->setText( + s.value("gpodder_device_name", GPodderSync::DefaultDeviceName()) + .toString()); + + if (dialog()->app()->gpodder_sync()->is_logged_in()) { + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn, + ui_->username->text()); + } + else { + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); + } +} + +void PodcastSettingsPage::Save() { + QSettings s; + s.beginGroup(kSettingsGroup); + + s.setValue("update_interval_secs", ui_->check_interval->itemData(ui_->check_interval->currentIndex())); + s.setValue("download_dir", + QDir::fromNativeSeparators(ui_->download_dir->text())); + s.setValue("auto_download", ui_->auto_download->isChecked()); + s.setValue("hide_listened", ui_->hide_listened->isChecked()); + s.setValue("delete_after", ui_->delete_after->value() * kSecsPerDay); + s.setValue("show_episodes", ui_->show_episodes->value()); + s.setValue("gpodder_device_name", ui_->device_name->text()); +} + +void PodcastSettingsPage::LoginClicked() { + ui_->login_state->SetLoggedIn(LoginStateWidget::LoginInProgress); + + dialog()->app()->gpodder_sync()->Login( + ui_->username->text(), ui_->password->text(), ui_->device_name->text()); +} + +void PodcastSettingsPage::GpodderLoginSuccess() { + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn, + ui_->username->text()); + ui_->login_state->SetAccountTypeVisible(false); +} + +void PodcastSettingsPage::GpodderLoginFailure(const QString& error) { + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut, + ui_->username->text()); + ui_->login_state->SetAccountTypeVisible(true); + ui_->login_state->SetAccountTypeText(tr("Login failed") + ": " + error); +} + +void PodcastSettingsPage::LogoutClicked() { + ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut); + ui_->password->clear(); + dialog()->app()->gpodder_sync()->Logout(); +} + +void PodcastSettingsPage::DownloadDirBrowse() { + QString directory = QFileDialog::getExistingDirectory( + this, tr("Choose podcast download directory"), ui_->download_dir->text()); + if (directory.isEmpty()) return; + + ui_->download_dir->setText(QDir::toNativeSeparators(directory)); +} diff --git a/src/settings/podcastsettingspage.h b/src/settings/podcastsettingspage.h new file mode 100644 index 00000000..ce3264f2 --- /dev/null +++ b/src/settings/podcastsettingspage.h @@ -0,0 +1,52 @@ +/* This file is part of Clementine. + Copyright 2012, David Sansome + Copyright 2014, John Maguire + Copyright 2014, Krzysztof Sobiecki + + Clementine 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. + + Clementine 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 Clementine. If not, see . +*/ + +#ifndef PODCASTSETTINGSPAGE_H +#define PODCASTSETTINGSPAGE_H + +#include "settingspage.h" + +class Ui_PodcastSettingsPage; + +class PodcastSettingsPage : public SettingsPage { + Q_OBJECT + + public: + explicit PodcastSettingsPage(SettingsDialog* dialog); + ~PodcastSettingsPage(); + + static const char* kSettingsGroup; + + void Load(); + void Save(); + + private slots: + void LoginClicked(); + void LogoutClicked(); + + void GpodderLoginSuccess(); + void GpodderLoginFailure(const QString& error); + + void DownloadDirBrowse(); + + private: + Ui_PodcastSettingsPage* ui_; +}; + +#endif // PODCASTSETTINGSPAGE_H diff --git a/src/settings/podcastsettingspage.ui b/src/settings/podcastsettingspage.ui new file mode 100644 index 00000000..bb42912e --- /dev/null +++ b/src/settings/podcastsettingspage.ui @@ -0,0 +1,290 @@ + + + PodcastSettingsPage + + + + 0 + 0 + 616 + 656 + + + + Podcasts + + + + + + Updating + + + + + + Check for new episodes + + + + + + + + Manually + + + + + Every 10 minutes + + + + + Every 20 minutes + + + + + Every 30 minutes + + + + + Every hour + + + + + Every 2 hours + + + + + Every 6 hours + + + + + Every 12 hours + + + + + + + + Download episodes to + + + + + + + Download new episodes automatically + + + + + + + + + + + + Browse... + + + + + + + + + + + + Cleaning up + + + + QFormLayout::AllNonFixedFieldsGrow + + + + + Delete played episodes + + + + + + + Manually + + + days + + + After + + + 30 + + + + + + + + + + + + + Appearance + + + + QFormLayout::AllNonFixedFieldsGrow + + + + + Don't show listened episodes + + + + + + + All + + + + + + + Number of episodes to show + + + + + + + + + + gpodder.net + + + + + + Clementine can synchronize your subscription list with your other computers and podcast applications. <a href="https://gpodder.net/register/">Create an account</a>. + + + true + + + true + + + + + + + + + + + 0 + + + + + Username + + + + + + + + + + + + Sign in + + + + + + + + + Password + + + + + + + QLineEdit::Password + + + + + + + Device name + + + + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + LoginStateWidget + QWidget +
widgets/loginstatewidget.h
+ 1 +
+
+ + check_interval + download_dir + download_dir_browse + auto_download + delete_after + username + password + device_name + login + + +
diff --git a/src/widgets/widgetfadehelper.cpp b/src/widgets/widgetfadehelper.cpp new file mode 100644 index 00000000..6f38ef73 --- /dev/null +++ b/src/widgets/widgetfadehelper.cpp @@ -0,0 +1,168 @@ +/* This file is part of Clementine. + Copyright 2010, David Sansome + + Clementine 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. + + Clementine 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 Clementine. If not, see . +*/ + +#include "widgetfadehelper.h" + +#include +#include +#include +#include + +#include "core/qt_blurimage.h" + +const int WidgetFadeHelper::kLoadingPadding = 9; +const int WidgetFadeHelper::kLoadingBorderRadius = 10; + +WidgetFadeHelper::WidgetFadeHelper(QWidget* parent, const int msec) + : QWidget(parent), + parent_(parent), + blur_timeline_(new QTimeLine(msec, this)), + fade_timeline_(new QTimeLine(msec, this)) { + parent->installEventFilter(this); + + connect(blur_timeline_, SIGNAL(valueChanged(qreal)), SLOT(update())); + connect(fade_timeline_, SIGNAL(valueChanged(qreal)), SLOT(update())); + connect(fade_timeline_, SIGNAL(finished()), SLOT(FadeFinished())); + + hide(); +} + +bool WidgetFadeHelper::eventFilter(QObject* obj, QEvent* event) { + // We're only interested in our parent's resize events + if (obj != parent_ || event->type() != QEvent::Resize) return false; + + // Don't care if we're hidden + if (!isVisible()) return false; + + QResizeEvent* re = static_cast(event); + if (re->oldSize() == re->size()) { + // Ignore phoney resize events + return false; + } + + // Get a new capture of the parent + hide(); + CaptureParent(); + show(); + return false; +} + +void WidgetFadeHelper::StartBlur() { + CaptureParent(); + + // Cover the parent + raise(); + show(); + + // Start the timeline + blur_timeline_->stop(); + blur_timeline_->start(); + + setAttribute(Qt::WA_TransparentForMouseEvents, false); +} + +void WidgetFadeHelper::CaptureParent() { + // Take a "screenshot" of the window + original_pixmap_ = parent_->grab(); + QImage original_image = original_pixmap_.toImage(); + + // Blur it + QImage blurred(original_image.size(), QImage::Format_ARGB32_Premultiplied); + blurred.fill(Qt::transparent); + + QPainter blur_painter(&blurred); + blur_painter.save(); + qt_blurImage(&blur_painter, original_image, 10.0, true, false); + blur_painter.restore(); + + // Draw some loading text over the top + QFont loading_font(font()); + loading_font.setBold(true); + QFontMetrics loading_font_metrics(loading_font); + + const QString loading_text = tr("Loading..."); + const QSize loading_size( + kLoadingPadding * 2 + loading_font_metrics.width(loading_text), + kLoadingPadding * 2 + loading_font_metrics.height()); + const QRect loading_rect((blurred.width() - loading_size.width()) / 2, 100, + loading_size.width(), loading_size.height()); + + blur_painter.setRenderHint(QPainter::Antialiasing); + blur_painter.setRenderHint(QPainter::HighQualityAntialiasing); + + blur_painter.translate(0.5, 0.5); + blur_painter.setPen(QColor(200, 200, 200, 255)); + blur_painter.setBrush(QColor(200, 200, 200, 192)); + blur_painter.drawRoundedRect(loading_rect, kLoadingBorderRadius, + kLoadingBorderRadius); + + blur_painter.setPen(palette().brush(QPalette::Text).color()); + blur_painter.setFont(loading_font); + blur_painter.drawText(loading_rect.translated(-1, -1), Qt::AlignCenter, + loading_text); + blur_painter.translate(-0.5, -0.5); + + blur_painter.end(); + + blurred_pixmap_ = QPixmap::fromImage(blurred); + + resize(parent_->size()); +} + +void WidgetFadeHelper::StartFade() { + + if (blur_timeline_->state() == QTimeLine::Running) { + // Blur timeline is still running, so we need render the current state + // into a new pixmap. + QPixmap pixmap(original_pixmap_); + QPainter painter(&pixmap); + painter.setOpacity(blur_timeline_->currentValue()); + painter.drawPixmap(0, 0, blurred_pixmap_); + painter.end(); + blurred_pixmap_ = pixmap; + } + blur_timeline_->stop(); + original_pixmap_ = QPixmap(); + + // Start the timeline + fade_timeline_->stop(); + fade_timeline_->start(); + + setAttribute(Qt::WA_TransparentForMouseEvents, true); + +} + +void WidgetFadeHelper::paintEvent(QPaintEvent*) { + QPainter p(this); + + if (fade_timeline_->state() != QTimeLine::Running) { + // We're fading in the blur + p.drawPixmap(0, 0, original_pixmap_); + p.setOpacity(blur_timeline_->currentValue()); + } else { + // Fading out the blur into the new image + p.setOpacity(1.0 - fade_timeline_->currentValue()); + } + + p.drawPixmap(0, 0, blurred_pixmap_); +} + +void WidgetFadeHelper::FadeFinished() { + hide(); + original_pixmap_ = QPixmap(); + blurred_pixmap_ = QPixmap(); +} diff --git a/src/widgets/widgetfadehelper.h b/src/widgets/widgetfadehelper.h new file mode 100644 index 00000000..3aae2e48 --- /dev/null +++ b/src/widgets/widgetfadehelper.h @@ -0,0 +1,57 @@ +/* This file is part of Clementine. + Copyright 2010, David Sansome + + Clementine 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. + + Clementine 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 Clementine. If not, see . +*/ + +#ifndef WIDGETFADEHELPER_H +#define WIDGETFADEHELPER_H + +#include + +class QTimeLine; + +class WidgetFadeHelper : public QWidget { + Q_OBJECT + + public: + WidgetFadeHelper(QWidget* parent, const int msec = 500); + + public slots: + void StartBlur(); + void StartFade(); + + protected: + void paintEvent(QPaintEvent*); + bool eventFilter(QObject* obj, QEvent* event); + + private slots: + void FadeFinished(); + + private: + void CaptureParent(); + + private: + static const int kLoadingPadding; + static const int kLoadingBorderRadius; + + QWidget* parent_; + QTimeLine* blur_timeline_; + QTimeLine* fade_timeline_; + + QPixmap original_pixmap_; + QPixmap blurred_pixmap_; +}; + +#endif // WIDGETFADEHELPER_H