Compare commits

..

34 Commits

Author SHA1 Message Date
Jonas Kvinge
46f368a68e Add podcasts support WIP 2025-12-29 00:40:01 +01:00
Strawberry Bot
da2f28811a New translations 2025-12-29 00:02:45 +01:00
Jonas Kvinge
0bfa736081 GstEnginePipeline: Add audioresample elements 2025-12-28 22:01:42 +01:00
Jonas Kvinge
1392bcbbe1 FilesystemMusicStorage: Fallback to delete if moving to trash fails
Fixes #1679
2025-12-28 21:28:49 +01:00
Jonas Kvinge
11705889f1 Show playlist load errors
Fixes #1470
2025-12-28 20:54:36 +01:00
dependabot[bot]
604dd2dbde Bump vmactions/freebsd-vm from 1.3.1 to 1.3.2
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.3.1 to 1.3.2.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: vmactions/freebsd-vm
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-28 19:00:35 +01:00
Stickman
25065ba98f Song: Include Opus for supported sort tags 2025-12-28 18:57:52 +01:00
Jonas Kvinge
7b16ec62bb Defer playcount and rating tag writes for currently playing Ogg songs
Fixes #1816
2025-12-28 18:33:49 +01:00
Jonas Kvinge
d8f31592b9 Remove settings member variables 2025-12-28 00:39:22 +01:00
Jonas Kvinge
80bb0f476d CollectionModel: Remove sort tags from container keys
Fixes #1899
2025-12-27 21:25:54 +01:00
dependabot[bot]
b7222ac85c Bump vmactions/openbsd-vm from 1.2.8 to 1.2.9
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.2.8 to 1.2.9.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.2.8...v1.2.9)

---
updated-dependencies:
- dependency-name: vmactions/openbsd-vm
  dependency-version: 1.2.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-27 20:02:32 +01:00
dependabot[bot]
241bca0828 Bump vmactions/openbsd-vm from 1.2.7 to 1.2.8
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.2.7 to 1.2.8.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.2.7...v1.2.8)

---
updated-dependencies:
- dependency-name: vmactions/openbsd-vm
  dependency-version: 1.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-25 19:51:35 +01:00
dependabot[bot]
90d86b10a3 Bump vmactions/freebsd-vm from 1.3.0 to 1.3.1
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.3.0 to 1.3.1.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.3.0...v1.3.1)

---
updated-dependencies:
- dependency-name: vmactions/freebsd-vm
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-25 16:51:53 +01:00
dependabot[bot]
4130c6670f Bump vmactions/openbsd-vm from 1.2.5 to 1.2.7
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.2.5 to 1.2.7.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.2.5...v1.2.7)

---
updated-dependencies:
- dependency-name: vmactions/openbsd-vm
  dependency-version: 1.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 21:10:30 +01:00
Jonas Kvinge
8d262959c1 GstEnginePipeline: Fix buffering issue near track end during gapless playback
Ignore buffering messages when within 5 seconds of track end and about-to-finish has been signaled. This prevents spurious buffering from blocking playback during track transitions with local files.

Fixes #1725
2025-12-20 01:36:49 +01:00
Jonas Kvinge
b9b70399d8 GstEnginePipeline: Fix possible race condition in pipeline destructor
Wait for ongoing state changes to complete before setting pipeline to NULL.
This prevents race conditions with async state transitions that can cause crashes in GStreamer elements.

Fixes #1875
2025-12-20 01:28:53 +01:00
Jonas Kvinge
527ccd212a SmartPlaylistsViewContainer: Ask for confirmation before resetting smart playlists 2025-12-19 01:03:46 +01:00
Jonas Kvinge
4a5afbeb1e SmartPlaylists: Add option to restore smart playlists to the defaults
Fixes #1848
2025-12-19 00:49:05 +01:00
Jonas Kvinge
63c14e014b EditTagDialog: Ignore unused const variables 2025-12-19 00:47:35 +01:00
Jonas Kvinge
801658c6b9 MainWindow: Check that current is the active playlist
Fixes #1783
2025-12-19 00:38:32 +01:00
Jonas Kvinge
16fe665295 TagReaderTagLib: Remove unused constants 2025-12-19 00:35:02 +01:00
Rob Stanfield
2bb0dbada2 Qobuz: Fix authentication and add automatic credential fetching
Qobuz API now requires intent=stream parameter for stream URL requests,
and the app_secret must be extracted using the Spoofbuz decoding method
from bundle.js rather than plain-text values.

Changes:
- Add intent=stream parameter to stream URL requests
- Add QobuzCredentialFetcher class to extract credentials from web player
- Add "Fetch Credentials" button to Qobuz settings page
- Decode obfuscated app secrets using seed/timezone/info/extras method

This fixes "Invalid Request Signature" errors that prevented playback.
2025-12-18 23:12:52 +01:00
Jonas Kvinge
2cd9498469 Add option to select ID3v2 version
Fixes #1861
2025-12-18 22:18:26 +01:00
Jonas Kvinge
d1ee27fff9 QobuzService: Remove QNetworkReply 2025-12-18 20:39:21 +01:00
Jonas Kvinge
91adf5ba32 NetworkAccessManager: Handle network state changes after system suspend/resume
Fixes #1521
2025-12-18 20:32:07 +01:00
Jonas Kvinge
d68f464269 Playlist: Don't automatically sort playlist before it's fully loaded
Fixes #1690
2025-12-18 20:14:36 +01:00
Jonas Kvinge
c684a95f89 GstEnginePipeline: Fix file descriptor exhaustion by using shared thread pool
Replace per-pipeline QThreadPool with a shared static pool to prevent
file descriptor and thread exhaustion. Each GstEnginePipeline was creating
its own thread pool, leading to resource accumulation during frequent
pipeline creation/destruction (track changes, seeking, crossfade).

The shared pool is limited to 2 threads max since state changes are
typically sequential per pipeline. This prevents the crash in g_wakeup_new()
when creating eventfd for new thread event dispatchers.

Fixes #1687
2025-12-18 19:58:23 +01:00
copilot-swe-agent[bot]
1d03bb2178 GstEnginePipeline: Fix crash in GStreamer decodebin3 when switching tracks
Add guard in AboutToFinishCallback to prevent race condition when pipeline is being torn down. This prevents the callback from trying to set next URL while the pipeline is being destroyed, which caused crashes in GStreamer's decodebin3.

Fixes issue where rapidly switching tracks could cause segmentation fault in gst_decodebin_input_link_to_slot.

See: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/4626

Fixes #1863

Co-Authored-By: Jonas Kvinge <jonas@jkvinge.net>
2025-12-18 19:44:03 +01:00
Jonas Kvinge
39f9128ecf gitignore: Add _codeql_detected_source_root 2025-12-18 19:39:10 +01:00
Jonas Kvinge
ca2e802239 GstEngine: Make sure device is set for pipeline
Fixes #1852
2025-12-18 00:21:00 +01:00
Jonas Kvinge
9a513a9a56 AutoExpandingTreeView: Scroll if cursor is out of visible area
Fixes #1489
2025-12-17 23:14:57 +01:00
Jonas Kvinge
1c2e87b741 Organize: Skip existing files if not overwriting
Fixes #1484
2025-12-17 22:58:17 +01:00
Jonas Kvinge
fe4d9979ce CollectionWatcher: Avoid re-scan of restored songs unless mtime is changed
Fixes #1819
2025-12-17 22:15:21 +01:00
Jonas Kvinge
d8ae790ebf Turn on git revision 2025-12-17 01:05:45 +01:00
121 changed files with 9681 additions and 188 deletions

View File

@@ -739,12 +739,18 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
- name: Free disk space
run: |
df -h
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
sudo apt-get clean
df -h
- name: Build FreeBSD
id: build-freebsd
uses: vmactions/freebsd-vm@v1.3.0
uses: vmactions/freebsd-vm@v1.3.2
with:
usesh: true
mem: 4096
mem: 8192
prepare: pkg install -y git cmake pkgconf boost-libs alsa-lib glib qt6-base qt6-tools sqlite gstreamer1 gstreamer1-plugins chromaprint libebur128 taglib libcdio libmtp gdk-pixbuf2 libgpod fftw3 icu kdsingleapplication googletest pulseaudio sparsehash rapidjson
run: |
set -e
@@ -766,7 +772,7 @@ jobs:
submodules: recursive
- name: Build OpenBSD
id: build-openbsd
uses: vmactions/openbsd-vm@v1.2.5
uses: vmactions/openbsd-vm@v1.2.9
with:
usesh: true
mem: 4096

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
/CMakeSettings.json
/dist/scripts/maketarball.sh
/debian/changelog
_codeql_detected_source_root

View File

@@ -208,6 +208,8 @@ else()
pkg_check_modules(TAGLIB REQUIRED IMPORTED_TARGET taglib>=1.12)
endif()
pkg_check_modules(LIBMYGPO libmygpo-qt6)
find_package(GTest)
pkg_check_modules(LIBSPARSEHASH IMPORTED_TARGET libsparsehash)
@@ -381,6 +383,8 @@ optional_component(DISCORD_RPC ON "Discord Rich Presence"
DEPENDS "RapidJSON" RapidJSON_FOUND
)
optional_component(PODCASTS ON "Podcasts support" DEPENDS "libmygpo" LIBMYGPO_FOUND)
if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ)
set(HAVE_CHROMAPRINT ON)
endif()
@@ -1463,6 +1467,7 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.cpp
src/qobuz/qobuzstreamurlrequest.cpp
src/qobuz/qobuzfavoriterequest.cpp
src/qobuz/qobuzcredentialfetcher.cpp
src/settings/qobuzsettingspage.cpp
src/covermanager/qobuzcoverprovider.cpp
HEADERS
@@ -1472,12 +1477,76 @@ optional_source(HAVE_QOBUZ
src/qobuz/qobuzrequest.h
src/qobuz/qobuzstreamurlrequest.h
src/qobuz/qobuzfavoriterequest.h
src/qobuz/qobuzcredentialfetcher.h
src/settings/qobuzsettingspage.h
src/covermanager/qobuzcoverprovider.h
UI
src/settings/qobuzsettingspage.ui
)
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
)
qt_wrap_cpp(SOURCES ${HEADERS})
qt_wrap_ui(SOURCES ${UI})
qt_add_resources(SOURCES data/data.qrc data/icons.qrc)

View File

@@ -3,7 +3,7 @@ set(STRAWBERRY_VERSION_MINOR 2)
set(STRAWBERRY_VERSION_PATCH 16)
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
set(INCLUDE_GIT_REVISION OFF)
set(INCLUDE_GIT_REVISION ON)
set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}")

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -189,6 +189,26 @@ void CollectionLibrary::ReloadSettings() {
}
void CollectionLibrary::CurrentSongChanged(const Song &song) {
current_song_url_ = song.url();
if (!pending_song_saves_.isEmpty()) {
SavePendingPlaycountsAndRatings();
}
}
void CollectionLibrary::Stopped() {
current_song_url_ = QUrl();
if (!pending_song_saves_.isEmpty()) {
SavePendingPlaycountsAndRatings();
}
}
void CollectionLibrary::SyncPlaycountAndRatingToFilesAsync() {
(void)QtConcurrent::run(&CollectionLibrary::SyncPlaycountAndRatingToFiles, this);
@@ -212,18 +232,85 @@ void CollectionLibrary::SyncPlaycountAndRatingToFiles() {
}
void CollectionLibrary::SongsPlaycountChanged(const SongList &songs, const bool save_tags) const {
void CollectionLibrary::SongsPlaycountChanged(const SongList &songs, const bool save_tags) {
if (save_tags || save_playcounts_to_files_) {
tagreader_client_->SaveSongsPlaycountAsync(songs);
SongList songs_to_save_now;
for (const Song &song : songs) {
if (song.url().isLocalFile() && song.url() == current_song_url_ &&
(song.filetype() == Song::FileType::OggFlac || song.filetype() == Song::FileType::OggVorbis || song.filetype() == Song::FileType::OggOpus)) {
qLog(Debug) << "Deferring playcount save for currently playing file" << song.url().toLocalFile();
if (pending_song_saves_.contains(song.url())) {
SharedPtr<PendingSongSave> pending_song_save = pending_song_saves_[song.url()];
pending_song_save->save_playcount = true;
pending_song_save->song.set_playcount(song.playcount());
}
else {
SharedPtr<PendingSongSave> pending_song_save = make_shared<PendingSongSave>();
pending_song_save->save_playcount = true;
pending_song_save->song = song;
pending_song_saves_.insert(song.url(), pending_song_save);
}
}
else {
songs_to_save_now << song;
}
}
if (!songs_to_save_now.isEmpty()) {
tagreader_client_->SaveSongsPlaycountAsync(songs_to_save_now);
}
}
}
void CollectionLibrary::SongsRatingChanged(const SongList &songs, const bool save_tags) const {
void CollectionLibrary::SongsRatingChanged(const SongList &songs, const bool save_tags) {
if (save_tags || save_ratings_to_files_) {
tagreader_client_->SaveSongsRatingAsync(songs);
SongList songs_to_save_now;
for (const Song &song : songs) {
if (song.url().isLocalFile() && song.url() == current_song_url_ &&
(song.filetype() == Song::FileType::OggFlac || song.filetype() == Song::FileType::OggVorbis || song.filetype() == Song::FileType::OggOpus)) {
qLog(Debug) << "Deferring rating save for currently playing file" << song.url().toLocalFile();
if (pending_song_saves_.contains(song.url())) {
SharedPtr<PendingSongSave> pending_song_save = pending_song_saves_[song.url()];
pending_song_save->save_rating = true;
pending_song_save->song.set_rating(song.rating());
}
else {
SharedPtr<PendingSongSave> pending_song_save = make_shared<PendingSongSave>();
pending_song_save->save_rating = true;
pending_song_save->song = song;
pending_song_saves_.insert(song.url(), pending_song_save);
}
}
else {
songs_to_save_now << song;
}
}
if (!songs_to_save_now.isEmpty()) {
tagreader_client_->SaveSongsRatingAsync(songs_to_save_now);
}
}
}
void CollectionLibrary::SavePendingPlaycountsAndRatings() {
for (auto it = pending_song_saves_.constBegin(); it != pending_song_saves_.constEnd();) {
const QUrl url = it.key();
SharedPtr<PendingSongSave> pending_song_save = it.value();
if (url == current_song_url_) {
++it;
continue;
}
qLog(Debug) << "Saving deferred playcount/rating for" << url.toLocalFile();
if (pending_song_save->save_playcount) {
tagreader_client_->SaveSongsPlaycountAsync(SongList() << pending_song_save->song);
}
if (pending_song_save->save_rating) {
tagreader_client_->SaveSongsRatingAsync(SongList() << pending_song_save->song);
}
it = pending_song_saves_.erase(it);
}
}

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -27,6 +27,7 @@
#include <QObject>
#include <QList>
#include <QHash>
#include <QMap>
#include <QString>
#include "includes/shared_ptr.h"
@@ -71,6 +72,7 @@ class CollectionLibrary : public QObject {
private:
void SyncPlaycountAndRatingToFiles();
void SavePendingPlaycountsAndRatings();
public Q_SLOTS:
void ReloadSettings();
@@ -84,16 +86,26 @@ class CollectionLibrary : public QObject {
void IncrementalScan();
void CurrentSongChanged(const Song &song);
void Stopped();
private Q_SLOTS:
void ExitReceived();
void SongsPlaycountChanged(const SongList &songs, const bool save_tags = false) const;
void SongsRatingChanged(const SongList &songs, const bool save_tags = false) const;
void SongsPlaycountChanged(const SongList &songs, const bool save_tags = false);
void SongsRatingChanged(const SongList &songs, const bool save_tags = false);
Q_SIGNALS:
void Error(const QString &error);
void ExitFinished();
private:
class PendingSongSave {
public:
Song song;
bool save_playcount = false;
bool save_rating = false;
};
const SharedPtr<TaskManager> task_manager_;
const SharedPtr<TagReaderClient> tagreader_client_;
@@ -111,6 +123,10 @@ class CollectionLibrary : public QObject {
bool save_playcounts_to_files_;
bool save_ratings_to_files_;
QUrl current_song_url_;
QMap<QUrl, SharedPtr<PendingSongSave>> pending_song_saves_;
};
#endif

View File

@@ -1209,49 +1209,41 @@ QString CollectionModel::ContainerKey(const GroupBy group_by, const Song &song,
switch (group_by) {
case GroupBy::AlbumArtist:
key = TextOrUnknown(song.effective_albumartist());
if (!song.effective_albumartistsort().isEmpty() && song.effective_albumartistsort() != song.effective_albumartist()) key.append(QLatin1Char('-') + TextOrUnknown(song.effective_albumartistsort()));
has_unique_album_identifier = true;
break;
case GroupBy::Artist:
key = TextOrUnknown(song.artist());
if (!song.artistsort().isEmpty() && song.artistsort() != song.artist()) key.append(QLatin1Char('-') + TextOrUnknown(song.artistsort()));
has_unique_album_identifier = true;
break;
case GroupBy::Album:
key = TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::AlbumDisc:
key = TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::YearAlbum:
key = SortTextForYear(song.year()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::YearAlbumDisc:
key = SortTextForYear(song.year()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::OriginalYearAlbum:
key = SortTextForYear(song.effective_originalyear()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
break;
case GroupBy::OriginalYearAlbumDisc:
key = SortTextForYear(song.effective_originalyear()) + QLatin1Char('-') + TextOrUnknown(song.album());
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
@@ -1270,12 +1262,10 @@ QString CollectionModel::ContainerKey(const GroupBy group_by, const Song &song,
break;
case GroupBy::Composer:
key = TextOrUnknown(song.composer());
if (!song.composersort().isEmpty() && song.composersort() != song.composer()) key.append(QLatin1Char('-') + song.composersort());
has_unique_album_identifier = true;
break;
case GroupBy::Performer:
key = TextOrUnknown(song.performer());
if (!song.performersort().isEmpty() && song.performersort() != song.performer()) key.append(QLatin1Char('-') + song.performersort());
has_unique_album_identifier = true;
break;
case GroupBy::Grouping:

View File

@@ -706,8 +706,14 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const CollectionSu
qLog(Debug) << file << "is missing EBU R 128 loudness characteristics.";
}
// If the song is unavailable and nothing has changed, just mark it as available without re-scanning
// For CUE files with multiple sections, all sections share the same file and would have the same availability status
if (matching_song.unavailable() && !changed && !missing_fingerprint && !missing_loudness_characteristics) {
qLog(Debug) << "Unavailable song" << file << "restored without re-scanning.";
t->readded_songs << matching_songs;
}
// The song's changed or missing fingerprint - create fingerprint and reread the metadata from file.
if (t->ignores_mtime() || changed || missing_fingerprint || missing_loudness_characteristics) {
else if (t->ignores_mtime() || changed || missing_fingerprint || missing_loudness_characteristics) {
QString fingerprint;
#ifdef HAVE_SONGFINGERPRINTING
@@ -728,12 +734,6 @@ void CollectionWatcher::ScanSubdirectory(const QString &path, const CollectionSu
}
}
// Nothing has changed - mark the song available without re-scanning
else if (matching_song.unavailable()) {
qLog(Debug) << "Unavailable song" << file << "restored.";
t->readded_songs << matching_songs;
}
}
else { // Search the DB by fingerprint.
QString fingerprint;

View File

@@ -33,6 +33,7 @@
#cmakedefine HAVE_SPOTIFY
#cmakedefine HAVE_QOBUZ
#cmakedefine HAVE_DISCORD_RPC
#cmakedefine HAVE_PODCASTS
#cmakedefine HAVE_TAGLIB_DSFFILE
#cmakedefine HAVE_TAGLIB_DSDIFFFILE

View File

@@ -113,7 +113,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 {
@@ -216,6 +225,21 @@ class ApplicationImpl {
#ifdef HAVE_MOODBAR
moodbar_loader_([app]() { return new MoodbarLoader(app); }),
moodbar_controller_([app]() { return new MoodbarController(app->player(), app->moodbar_loader()); }),
#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()); })
{}
@@ -241,6 +265,13 @@ class ApplicationImpl {
#ifdef HAVE_MOODBAR
Lazy<MoodbarLoader> moodbar_loader_;
Lazy<MoodbarController> moodbar_controller_;
#endif
#ifdef HAVE_PODCASTS
Lazy<PodcastBackend> podcast_backend_;
Lazy<GPodderSync> gpodder_sync_;
Lazy<PodcastDownloader> podcast_downloader_;
Lazy<PodcastUpdater> podcast_updater_;
Lazy<PodcastDeleter> podcast_deleter_;
#endif
Lazy<LastFMImport> lastfm_import_;
@@ -390,3 +421,10 @@ SharedPtr<LastFMImport> Application::lastfm_import() const { return p_->lastfm_i
SharedPtr<MoodbarController> Application::moodbar_controller() const { return p_->moodbar_controller_.ptr(); }
SharedPtr<MoodbarLoader> 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

View File

@@ -63,6 +63,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
@@ -102,6 +110,13 @@ class Application : public QObject {
SharedPtr<MoodbarController> moodbar_controller() const;
SharedPtr<MoodbarLoader> 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<LastFMImport> lastfm_import() const;

View File

@@ -110,21 +110,32 @@ bool FilesystemMusicStorage::CopyToStorage(const CopyJob &job, QString &error_te
bool FilesystemMusicStorage::DeleteFromStorage(const DeleteJob &job) {
QString path = job.metadata_.url().toLocalFile();
QFileInfo fileInfo(path);
const QString path = job.metadata_.url().toLocalFile();
const QFileInfo fileInfo(path);
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
if (job.use_trash_ && QFile::supportsMoveToTrash()) {
#else
if (job.use_trash_) {
#endif
return QFile::moveToTrash(path);
if (QFile::moveToTrash(path)) {
return true;
}
qLog(Warning) << "Moving file to trash failed for" << path << ", falling back to direct deletion";
}
bool success = false;
if (fileInfo.isDir()) {
return Utilities::RemoveRecursive(path);
success = Utilities::RemoveRecursive(path);
}
else {
success = QFile::remove(path);
}
return QFile::remove(path);
if (!success) {
qLog(Error) << "Failed to delete file" << path;
}
return success;
}

View File

@@ -58,7 +58,6 @@
#include <QShortcut>
#include <QMessageBox>
#include <QErrorMessage>
#include <QSettings>
#include <QColor>
#include <QFrame>
#include <QItemSelectionModel>
@@ -697,6 +696,9 @@ MainWindow::MainWindow(Application *app,
QObject::connect(&*app_->task_manager(), &TaskManager::PauseCollectionWatchers, &*app_->collection(), &CollectionLibrary::PauseWatcher);
QObject::connect(&*app_->task_manager(), &TaskManager::ResumeCollectionWatchers, &*app_->collection(), &CollectionLibrary::ResumeWatcher);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->collection(), &CollectionLibrary::CurrentSongChanged);
QObject::connect(&*app_->player(), &Player::Stopped, &*app_->collection(), &CollectionLibrary::Stopped);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->current_albumcover_loader(), &CurrentAlbumCoverLoader::LoadAlbumCover);
QObject::connect(&*app_->current_albumcover_loader(), &CurrentAlbumCoverLoader::AlbumCoverLoaded, this, &MainWindow::AlbumCoverLoaded);
QObject::connect(album_cover_choice_controller_, &AlbumCoverChoiceController::Error, this, &MainWindow::ShowErrorDialog);
@@ -977,27 +979,28 @@ MainWindow::MainWindow(Application *app,
// Load settings
qLog(Debug) << "Loading settings";
settings_.beginGroup(MainWindowSettings::kSettingsGroup);
Settings settings;
settings.beginGroup(MainWindowSettings::kSettingsGroup);
// Set last used geometry to position window on the correct monitor
// Set window state only if the window was last maximized
if (settings_.contains("geometry")) {
restoreGeometry(settings_.value("geometry").toByteArray());
if (settings.contains("geometry")) {
restoreGeometry(settings.value("geometry").toByteArray());
}
if (!settings_.contains(MainWindowSettings::kSplitterState) || !ui_->splitter->restoreState(settings_.value(MainWindowSettings::kSplitterState).toByteArray())) {
if (!settings.contains(MainWindowSettings::kSplitterState) || !ui_->splitter->restoreState(settings.value(MainWindowSettings::kSplitterState).toByteArray())) {
ui_->splitter->setSizes(QList<int>() << 20 << (width() - 20));
}
ui_->tabs->setCurrentIndex(settings_.value("current_tab", 1).toInt());
ui_->tabs->setCurrentIndex(settings.value("current_tab", 1).toInt());
FancyTabWidget::Mode default_mode = FancyTabWidget::Mode::LargeSidebar;
FancyTabWidget::Mode tab_mode = static_cast<FancyTabWidget::Mode>(settings_.value("tab_mode", static_cast<int>(default_mode)).toInt());
FancyTabWidget::Mode tab_mode = static_cast<FancyTabWidget::Mode>(settings.value("tab_mode", static_cast<int>(default_mode)).toInt());
if (tab_mode == FancyTabWidget::Mode::None) tab_mode = default_mode;
ui_->tabs->SetMode(tab_mode);
TabSwitched();
file_view_->SetPath(settings_.value("file_path", QDir::homePath()).toString());
file_view_->SetPath(settings.value("file_path", QDir::homePath()).toString());
// Users often collapse one side of the splitter by mistake and don't know how to restore it. This must be set after the state is restored above.
ui_->splitter->setChildrenCollapsible(false);
@@ -1040,13 +1043,13 @@ MainWindow::MainWindow(Application *app,
case BehaviourSettings::StartupBehaviour::Remember:
default:{
was_maximized_ = settings_.value(MainWindowSettings::kMaximized, true).toBool();
was_maximized_ = settings.value(MainWindowSettings::kMaximized, true).toBool();
if (was_maximized_) setWindowState(windowState() | Qt::WindowMaximized);
was_minimized_ = settings_.value(MainWindowSettings::kMinimized, false).toBool();
was_minimized_ = settings.value(MainWindowSettings::kMinimized, false).toBool();
if (was_minimized_) setWindowState(windowState() | Qt::WindowMinimized);
if (!systemtrayicon_->IsSystemTrayAvailable() || !systemtrayicon_->isVisible() || !settings_.value(MainWindowSettings::kHidden, false).toBool()) {
if (!systemtrayicon_->IsSystemTrayAvailable() || !systemtrayicon_->isVisible() || !settings.value(MainWindowSettings::kHidden, false).toBool()) {
show();
}
break;
@@ -1054,7 +1057,7 @@ MainWindow::MainWindow(Application *app,
}
#endif
bool show_sidebar = settings_.value(MainWindowSettings::kShowSidebar, true).toBool();
bool show_sidebar = settings.value(MainWindowSettings::kShowSidebar, true).toBool();
ui_->sidebar_layout->setVisible(show_sidebar);
ui_->action_toggle_show_sidebar->setChecked(show_sidebar);
@@ -1225,7 +1228,9 @@ void MainWindow::ReloadSettings() {
osd_->ReloadSettings();
album_cover_choice_controller_->search_cover_auto_action()->setChecked(settings_.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
s.beginGroup(MainWindowSettings::kSettingsGroup);
album_cover_choice_controller_->search_cover_auto_action()->setChecked(s.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
s.endGroup();
#ifdef HAVE_SUBSONIC
s.beginGroup(SubsonicSettings::kSettingsGroup);
@@ -1342,8 +1347,11 @@ void MainWindow::SaveSettings() {
ui_->playlist->view()->SaveSettings();
app_->scrobbler()->WriteCache();
settings_.setValue(MainWindowSettings::kShowSidebar, ui_->action_toggle_show_sidebar->isChecked());
settings_.setValue(MainWindowSettings::kSearchForCoverAuto, album_cover_choice_controller_->search_cover_auto_action()->isChecked());
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kShowSidebar, ui_->action_toggle_show_sidebar->isChecked());
s.setValue(MainWindowSettings::kSearchForCoverAuto, album_cover_choice_controller_->search_cover_auto_action()->isChecked());
s.endGroup();
}
@@ -1584,23 +1592,35 @@ void MainWindow::ToggleSidebar(const bool checked) {
ui_->sidebar_layout->setVisible(checked);
TabSwitched();
settings_.setValue(MainWindowSettings::kShowSidebar, checked);
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kShowSidebar, checked);
s.endGroup();
}
void MainWindow::ToggleSearchCoverAuto(const bool checked) {
settings_.setValue(MainWindowSettings::kSearchForCoverAuto, checked);
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kSearchForCoverAuto, checked);
s.endGroup();
}
void MainWindow::SaveGeometry() {
if (!initialized_) return;
settings_.setValue(MainWindowSettings::kMaximized, isMaximized());
settings_.setValue(MainWindowSettings::kMinimized, isMinimized());
settings_.setValue(MainWindowSettings::kHidden, isHidden());
settings_.setValue(MainWindowSettings::kGeometry, saveGeometry());
settings_.setValue(MainWindowSettings::kSplitterState, ui_->splitter->saveState());
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue(MainWindowSettings::kMaximized, isMaximized());
s.setValue(MainWindowSettings::kMinimized, isMinimized());
s.setValue(MainWindowSettings::kHidden, isHidden());
s.setValue(MainWindowSettings::kGeometry, saveGeometry());
s.setValue(MainWindowSettings::kSplitterState, ui_->splitter->saveState());
s.endGroup();
}
@@ -1742,7 +1762,12 @@ void MainWindow::SetHiddenInTray(const bool hidden) {
}
void MainWindow::FilePathChanged(const QString &path) {
settings_.setValue("file_path", path);
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
s.setValue("file_path", path);
s.endGroup();
}
void MainWindow::Seeked(const qint64 microseconds) {
@@ -2324,7 +2349,9 @@ void MainWindow::EditValue() {
void MainWindow::AddFile() {
// Last used directory
QString directory = settings_.value("add_media_path", QDir::currentPath()).toString();
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
QString directory = s.value("add_media_path", QDir::currentPath()).toString();
PlaylistParser parser(app_->tagreader_client(), app_->collection_backend());
@@ -2334,7 +2361,7 @@ void MainWindow::AddFile() {
if (filenames.isEmpty()) return;
// Save last used directory
settings_.setValue("add_media_path", filenames[0]);
s.setValue("add_media_path", filenames[0]);
// Convert to URLs
QList<QUrl> urls;
@@ -2352,14 +2379,16 @@ void MainWindow::AddFile() {
void MainWindow::AddFolder() {
// Last used directory
QString directory = settings_.value("add_folder_path", QDir::currentPath()).toString();
Settings s;
s.beginGroup(MainWindowSettings::kSettingsGroup);
QString directory = s.value("add_folder_path", QDir::currentPath()).toString();
// Show dialog
directory = QFileDialog::getExistingDirectory(this, tr("Add folder"), directory);
if (directory.isEmpty()) return;
// Save last used directory
settings_.setValue("add_folder_path", directory);
s.setValue("add_folder_path", directory);
// Add media
MimeData *mimedata = new MimeData;
@@ -3318,7 +3347,7 @@ void MainWindow::PlaylistDelete() {
if (DeleteConfirmationDialog::warning(files) != QDialogButtonBox::Yes) return;
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current() == app_->playlist_manager()->active() && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
app_->player()->Stop();
}

View File

@@ -45,7 +45,6 @@
#include <QImage>
#include <QPixmap>
#include <QTimer>
#include <QSettings>
#include <QtEvents>
#include "includes/scoped_ptr.h"
@@ -53,7 +52,6 @@
#include "includes/lazy.h"
#include "core/platforminterface.h"
#include "core/song.h"
#include "core/settings.h"
#include "core/commandlineoptions.h"
#include "tagreader/tagreaderclient.h"
#include "osd/osdbase.h"
@@ -390,7 +388,6 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QTimer *track_position_timer_;
QTimer *track_slider_timer_;
Settings settings_;
bool keep_running_;
bool playing_widget_;

View File

@@ -29,6 +29,7 @@
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QNetworkInformation>
#include "networkaccessmanager.h"
#include "threadsafenetworkdiskcache.h"
@@ -41,6 +42,22 @@ NetworkAccessManager::NetworkAccessManager(QObject *parent)
setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
setCache(new ThreadSafeNetworkDiskCache(this));
// Handle network state changes after system suspend/resume
// QNetworkInformation provides cross-platform network reachability monitoring in Qt 6
if (QNetworkInformation::loadDefaultBackend()) {
QNetworkInformation *network_info = QNetworkInformation::instance();
if (network_info) {
QObject::connect(network_info, &QNetworkInformation::reachabilityChanged, this, [this](QNetworkInformation::Reachability reachability) {
if (reachability == QNetworkInformation::Reachability::Online) {
// Clear connection cache to force reconnection after network becomes available
// This fixes issues after system suspend/resume
clearConnectionCache();
clearAccessCache();
}
});
}
}
}
QNetworkReply *NetworkAccessManager::createRequest(Operation op, const QNetworkRequest &network_request, QIODevice *outgoing_data) {

View File

@@ -353,6 +353,8 @@ struct Song::Private : public QSharedData {
std::optional<double> ebur128_integrated_loudness_lufs_;
std::optional<double> ebur128_loudness_range_lu_;
int id3v2_version_; // ID3v2 tag version (3 or 4), 0 if not applicable or unknown
bool init_from_file_; // Whether this song was loaded from a file using taglib.
bool suspicious_tags_; // Whether our encoding guesser thinks these tags might be incorrectly encoded.
@@ -400,6 +402,8 @@ Song::Private::Private(const Source source)
rating_(-1),
bpm_(-1),
id3v2_version_(0),
init_from_file_(false),
suspicious_tags_(false)
@@ -510,6 +514,8 @@ const QString &Song::musicbrainz_work_id() const { return d->musicbrainz_work_id
std::optional<double> Song::ebur128_integrated_loudness_lufs() const { return d->ebur128_integrated_loudness_lufs_; }
std::optional<double> Song::ebur128_loudness_range_lu() const { return d->ebur128_loudness_range_lu_; }
int Song::id3v2_version() const { return d->id3v2_version_; }
QString *Song::mutable_title() { return &d->title_; }
QString *Song::mutable_album() { return &d->album_; }
QString *Song::mutable_artist() { return &d->artist_; }
@@ -624,6 +630,8 @@ void Song::set_musicbrainz_work_id(const QString &v) { d->musicbrainz_work_id_ =
void Song::set_ebur128_integrated_loudness_lufs(const std::optional<double> v) { d->ebur128_integrated_loudness_lufs_ = v; }
void Song::set_ebur128_loudness_range_lu(const std::optional<double> v) { d->ebur128_loudness_range_lu_ = v; }
void Song::set_id3v2_version(const int v) { d->id3v2_version_ = v; }
void Song::set_init_from_file(const bool v) { d->init_from_file_ = v; }
void Song::set_stream_url(const QUrl &v) { d->stream_url_ = v; }
@@ -797,19 +805,19 @@ bool Song::lyrics_supported() const {
}
bool Song::albumartistsort_supported() const {
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
}
bool Song::albumsort_supported() const {
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
}
bool Song::artistsort_supported() const {
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
}
bool Song::composersort_supported() const {
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
}
bool Song::performersort_supported() const {
@@ -818,7 +826,7 @@ bool Song::performersort_supported() const {
}
bool Song::titlesort_supported() const {
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
}
bool Song::save_embedded_cover_supported(const FileType filetype) {
@@ -833,6 +841,10 @@ bool Song::save_embedded_cover_supported(const FileType filetype) {
}
bool Song::id3v2_tags_supported() const {
return d->filetype_ == FileType::MPEG || d->filetype_ == FileType::WAV || d->filetype_ == FileType::AIFF;
}
int Song::ColumnIndex(const QString &field) {
return static_cast<int>(kRowIdColumns.indexOf(field));

View File

@@ -234,6 +234,8 @@ class Song {
std::optional<double> ebur128_integrated_loudness_lufs() const;
std::optional<double> ebur128_loudness_range_lu() const;
int id3v2_version() const;
QString *mutable_title();
QString *mutable_album();
QString *mutable_artist();
@@ -349,6 +351,8 @@ class Song {
void set_ebur128_integrated_loudness_lufs(const std::optional<double> v);
void set_ebur128_loudness_range_lu(const std::optional<double> v);
void set_id3v2_version(const int v);
void set_init_from_file(const bool v);
void set_stream_url(const QUrl &v);
@@ -439,6 +443,8 @@ class Song {
static bool save_embedded_cover_supported(const FileType filetype);
bool save_embedded_cover_supported() const { return url().isLocalFile() && save_embedded_cover_supported(filetype()) && !has_cue(); };
bool id3v2_tags_supported() const;
static int ColumnIndex(const QString &field);
static QString JoinSpec(const QString &table);

View File

@@ -98,6 +98,9 @@ SongLoader::SongLoader(const SharedPtr<UrlHandlers> url_handlers,
QObject::connect(timeout_timer_, &QTimer::timeout, this, &SongLoader::Timeout);
QObject::connect(playlist_parser_, &PlaylistParser::Error, this, &SongLoader::ParserError);
QObject::connect(cue_parser_, &CueParser::Error, this, &SongLoader::ParserError);
}
SongLoader::~SongLoader() {
@@ -106,6 +109,10 @@ SongLoader::~SongLoader() {
}
void SongLoader::ParserError(const QString &error) {
errors_ << error;
}
SongLoader::Result SongLoader::Load(const QUrl &url) {
if (url.isEmpty()) return Result::Error;
@@ -287,6 +294,7 @@ SongLoader::Result SongLoader::LoadLocalAsync(const QString &filename) {
}
if (parser) { // It's a playlist!
QObject::connect(parser, &ParserBase::Error, this, &SongLoader::ParserError, static_cast<Qt::ConnectionType>(Qt::QueuedConnection | Qt::UniqueConnection));
qLog(Debug) << "Parsing using" << parser->name();
LoadPlaylist(parser, filename);
return Result::Success;
@@ -706,6 +714,10 @@ void SongLoader::MagicReady() {
StopTypefindAsync(true);
}
if (parser_) {
QObject::connect(parser_, &ParserBase::Error, this, &SongLoader::ParserError, static_cast<Qt::ConnectionType>(Qt::QueuedConnection | Qt::UniqueConnection));
}
state_ = State::WaitingForData;
if (!IsPipelinePlaying()) {

View File

@@ -99,6 +99,7 @@ class SongLoader : public QObject {
void ScheduleTimeout();
void Timeout();
void StopTypefind();
void ParserError(const QString &error);
#ifdef HAVE_AUDIOCD
void AudioCDTracksLoadErrorSlot(const QString &error);

View File

@@ -81,6 +81,7 @@
#include "utilities/coverutils.h"
#include "utilities/coveroptions.h"
#include "tagreader/tagreaderclient.h"
#include "tagreader/tagid3v2version.h"
#include "widgets/busyindicator.h"
#include "widgets/lineedit.h"
#include "collection/collectionbackend.h"
@@ -104,14 +105,29 @@
using std::make_shared;
using namespace Qt::Literals::StringLiterals;
#ifdef __clang__
# pragma clang diagnostic push
# pragma clang diagnostic ignored "-Wunused-const-variable"
#endif
namespace {
constexpr char kSettingsGroup[] = "EditTagDialog";
constexpr int kSmallImageSize = 128;
// ID3v2 version constants
constexpr int kID3v2_Version_3 = 3;
constexpr int kID3v2_Version_4 = 4;
constexpr int kComboBoxIndex_ID3v2_3 = 0;
constexpr int kComboBoxIndex_ID3v2_4 = 1;
} // namespace
const char EditTagDialog::kTagsDifferentHintText[] = QT_TR_NOOP("(different across multiple songs)");
const char EditTagDialog::kArtDifferentHintText[] = QT_TR_NOOP("Different art across multiple songs.");
#ifdef __clang_
# pragma clang diagnostic pop
#endif
EditTagDialog::EditTagDialog(const SharedPtr<NetworkAccessManager> network,
const SharedPtr<TagReaderClient> tagreader_client,
const SharedPtr<CollectionBackend> collection_backend,
@@ -708,6 +724,9 @@ void EditTagDialog::SelectionChanged() {
bool titlesort_enabled = false;
bool artistsort_enabled = false;
bool albumsort_enabled = false;
bool has_id3v2_support = false;
int id3v2_version = 0;
bool id3v2_version_different = false;
for (const QModelIndex &idx : indexes) {
if (data_.value(idx.row()).cover_action_ == UpdateCoverAction::None) {
data_[idx.row()].cover_result_ = AlbumCoverImageResult();
@@ -769,6 +788,15 @@ void EditTagDialog::SelectionChanged() {
if (song.albumsort_supported()) {
albumsort_enabled = true;
}
if (song.id3v2_tags_supported()) {
has_id3v2_support = true;
if (id3v2_version == 0) {
id3v2_version = song.id3v2_version();
}
else if (id3v2_version != song.id3v2_version()) {
id3v2_version_different = true;
}
}
}
QString summary;
@@ -840,6 +868,23 @@ void EditTagDialog::SelectionChanged() {
ui_->artistsort->setEnabled(artistsort_enabled);
ui_->albumsort->setEnabled(albumsort_enabled);
ui_->label_id3v2_version->setVisible(has_id3v2_support);
ui_->combobox_id3v2_version->setVisible(has_id3v2_support);
if (has_id3v2_support) {
// Set default based on existing version(s)
if (id3v2_version_different || id3v2_version == 0) {
// Mixed versions or unknown - default to ID3v2.4
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_4);
}
else if (id3v2_version == kID3v2_Version_3) {
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_3);
}
else {
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_4);
}
}
}
void EditTagDialog::UpdateUI(const QModelIndexList &indexes) {
@@ -1371,6 +1416,13 @@ void EditTagDialog::SaveData() {
}
save_tag_cover_data.cover_data = ref.cover_result_.image_data;
}
// Determine ID3v2 version based on user selection
TagID3v2Version tag_id3v2_version = TagID3v2Version::Default;
if (ref.current_.filetype() == Song::FileType::MPEG || ref.current_.filetype() == Song::FileType::WAV || ref.current_.filetype() == Song::FileType::AIFF) {
tag_id3v2_version = ui_->combobox_id3v2_version->currentIndex() == kComboBoxIndex_ID3v2_3 ? TagID3v2Version::V3 : TagID3v2Version::V4;
}
TagReaderClient::SaveOptions save_tags_options;
if (save_tags) {
save_tags_options |= TagReaderClient::SaveOption::Tags;
@@ -1384,7 +1436,7 @@ void EditTagDialog::SaveData() {
if (save_embedded_cover) {
save_tags_options |= TagReaderClient::SaveOption::Cover;
}
TagReaderReplyPtr reply = tagreader_client_->WriteFileAsync(ref.current_.url().toLocalFile(), ref.current_, save_tags_options, save_tag_cover_data);
TagReaderReplyPtr reply = tagreader_client_->WriteFileAsync(ref.current_.url().toLocalFile(), ref.current_, save_tags_options, save_tag_cover_data, tag_id3v2_version);
SharedPtr<QMetaObject::Connection> connection = make_shared<QMetaObject::Connection>();
*connection = QObject::connect(&*reply, &TagReaderReply::Finished, this, [this, reply, ref, connection]() {
SongSaveTagsComplete(reply, ref.current_.url().toLocalFile(), ref.current_, ref.cover_action_);

View File

@@ -650,6 +650,47 @@
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="layout_id3v2_version">
<item>
<widget class="QLabel" name="label_id3v2_version">
<property name="text">
<string>ID3v2 version:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="combobox_id3v2_version">
<property name="enabled">
<bool>true</bool>
</property>
<item>
<property name="text">
<string>2.3</string>
</property>
</item>
<item>
<property name="text">
<string>2.4</string>
</property>
</item>
</widget>
</item>
<item>
<spacer name="spacer_id3v2_version">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<spacer name="spacer_albumart_bottom">
<property name="orientation">

View File

@@ -57,6 +57,7 @@
#include "core/enginemetadata.h"
#include "constants/timeconstants.h"
#include "enginebase.h"
#include "gsturl.h"
#include "gstengine.h"
#include "gstenginepipeline.h"
#include "gstbufferconsumer.h"
@@ -179,15 +180,18 @@ EngineBase::State GstEngine::state() const {
void GstEngine::StartPreloading(const QUrl &media_url, const QUrl &stream_url, const bool force_stop_at_end, const qint64 beginning_offset_nanosec, const qint64 end_offset_nanosec) {
const QByteArray gst_url = FixupUrl(stream_url);
const GstUrl gst_url = FixupUrl(stream_url);
// No crossfading, so we can just queue the new URL in the existing pipeline and get gapless playback (hopefully)
if (current_pipeline_) {
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_offset_nanosec, force_stop_at_end ? end_offset_nanosec : 0);
if (!gst_url.source_device.isEmpty()) {
current_pipeline_->SetSourceDevice(gst_url.source_device);
}
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url.url, beginning_offset_nanosec, force_stop_at_end ? end_offset_nanosec : 0);
// Add request to discover the stream
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url.url;
}
}
}
@@ -198,7 +202,7 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
EngineBase::Load(media_url, stream_url, change, force_stop_at_end, beginning_offset_nanosec, end_offset_nanosec, ebur128_integrated_loudness_lufs);
const QByteArray gst_url = FixupUrl(stream_url);
const GstUrl gst_url = FixupUrl(stream_url);
bool crossfade = current_pipeline_ && ((crossfade_enabled_ && change & EngineBase::TrackChangeType::Manual) || (autocrossfade_enabled_ && change & EngineBase::TrackChangeType::Auto) || ((crossfade_enabled_ || autocrossfade_enabled_) && change & EngineBase::TrackChangeType::Intro));
@@ -215,9 +219,14 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
}
}
GstEnginePipelinePtr pipeline = CreatePipeline(media_url, stream_url, gst_url, static_cast<qint64>(beginning_offset_nanosec), force_stop_at_end ? end_offset_nanosec : 0, ebur128_loudness_normalizing_gain_db_);
GstEnginePipelinePtr pipeline = CreatePipeline(media_url, stream_url, gst_url.url, static_cast<qint64>(beginning_offset_nanosec), force_stop_at_end ? end_offset_nanosec : 0, ebur128_loudness_normalizing_gain_db_);
if (!pipeline) return false;
// Set the source device if one was extracted from the URL
if (!gst_url.source_device.isEmpty()) {
pipeline->SetSourceDevice(gst_url.source_device);
}
GstEnginePipelinePtr old_pipeline = current_pipeline_;
current_pipeline_ = pipeline;
@@ -253,8 +262,8 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
// Add request to discover the stream
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url;
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
qLog(Error) << "Failed to start stream discovery for" << gst_url.url;
}
}
@@ -814,16 +823,16 @@ void GstEngine::BufferingFinished() {
}
QByteArray GstEngine::FixupUrl(const QUrl &url) {
GstUrl GstEngine::FixupUrl(const QUrl &url) {
QByteArray uri;
GstUrl gst_url;
// It's a file:// url with a hostname set.
// QUrl::fromLocalFile does this when given a \\host\share\file path on Windows.
// Munge it back into a path that gstreamer will recognise.
if (url.isLocalFile() && !url.host().isEmpty()) {
QString str = "file:////"_L1 + url.host() + url.path();
uri = str.toUtf8();
gst_url.url = str.toUtf8();
}
else if (url.scheme() == "cdda"_L1) {
QString str;
@@ -837,16 +846,15 @@ QByteArray GstEngine::FixupUrl(const QUrl &url) {
// We keep the device in mind, and we will set it later using SourceSetupCallback
QStringList path = url.path().split(u'/');
str = QStringLiteral("cdda://%1").arg(path.takeLast());
QString device = path.join(u'/');
if (current_pipeline_) current_pipeline_->SetSourceDevice(device);
gst_url.source_device = path.join(u'/');
}
uri = str.toUtf8();
gst_url.url = str.toUtf8();
}
else {
uri = url.toEncoded();
gst_url.url = url.toEncoded();
}
return uri;
return gst_url;
}

View File

@@ -41,6 +41,7 @@
#include "includes/shared_ptr.h"
#include "enginebase.h"
#include "gsturl.h"
#include "gstenginepipeline.h"
#include "gstbufferconsumer.h"
@@ -123,7 +124,7 @@ class GstEngine : public EngineBase, public GstBufferConsumer {
void PipelineFinished(const int pipeline_id);
private:
QByteArray FixupUrl(const QUrl &url);
GstUrl FixupUrl(const QUrl &url);
void StartFadeout(GstEnginePipelinePtr pipeline);
void StartFadeoutPause();

View File

@@ -26,6 +26,7 @@
#include <cstdint>
#include <cstring>
#include <cmath>
#include <algorithm>
#include <glib.h>
#include <glib-object.h>
@@ -42,6 +43,7 @@
#include <QObject>
#include <QCoreApplication>
#include <QtConcurrentRun>
#include <QThreadPool>
#include <QFuture>
#include <QFutureWatcher>
#include <QMutex>
@@ -90,6 +92,9 @@ constexpr std::chrono::milliseconds kFaderTimeoutMsec = 3000ms;
constexpr int kEqBandCount = 10;
constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000 };
// When within this many seconds of track end during gapless playback, ignore buffering messages
constexpr int kIgnoreBufferingNearEndSeconds = 5;
} // namespace
#ifdef __clang_
@@ -98,6 +103,23 @@ constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 1200
int GstEnginePipeline::sId = 1;
QThreadPool *GstEnginePipeline::shared_state_threadpool() {
// C++11 guarantees thread-safe initialization of static local variables
static QThreadPool pool;
static const auto init = []() {
// Limit the number of threads to prevent resource exhaustion
// Use 2 threads max since state changes are typically sequential per pipeline
pool.setMaxThreadCount(2);
return true;
}();
Q_UNUSED(init);
return &pool;
}
GstEnginePipeline::GstEnginePipeline(QObject *parent)
: QObject(parent),
id_(sId++),
@@ -156,7 +178,6 @@ GstEnginePipeline::GstEnginePipeline(QObject *parent)
audiobin_(nullptr),
audiosink_(nullptr),
audioqueue_(nullptr),
audioqueueconverter_(nullptr),
volume_(nullptr),
volume_sw_(nullptr),
volume_fading_(nullptr),
@@ -165,6 +186,7 @@ GstEnginePipeline::GstEnginePipeline(QObject *parent)
equalizer_(nullptr),
equalizer_preamp_(nullptr),
eventprobe_(nullptr),
bufferprobe_(nullptr),
logged_unsupported_analyzer_format_(false),
about_to_finish_(false),
finish_requested_(false),
@@ -197,6 +219,23 @@ GstEnginePipeline::~GstEnginePipeline() {
if (pipeline_) {
// Wait for any ongoing state changes for this pipeline to complete before setting to NULL.
// This prevents race conditions with async state transitions.
{
// Copy futures to local list to avoid holding mutex during waitForFinished()
QList<QFuture<GstStateChangeReturn>> futures_to_wait;
{
QMutexLocker locker(&mutex_pending_state_changes_);
futures_to_wait = pending_state_changes_;
pending_state_changes_.clear();
}
// Wait for all pending futures to complete
for (QFuture<GstStateChangeReturn> &future : futures_to_wait) {
future.waitForFinished();
}
}
gst_element_set_state(pipeline_, GST_STATE_NULL);
GstElement *audiobin = nullptr;
@@ -397,7 +436,7 @@ void GstEnginePipeline::Disconnect() {
}
if (buffer_probe_cb_id_.has_value()) {
GstPad *pad = gst_element_get_static_pad(audioqueueconverter_, "src");
GstPad *pad = gst_element_get_static_pad(bufferprobe_, "src");
if (pad) {
gst_pad_remove_probe(pad, buffer_probe_cb_id_.value());
gst_object_unref(pad);
@@ -635,8 +674,13 @@ bool GstEnginePipeline::InitAudioBin(QString &error) {
return false;
}
audioqueueconverter_ = CreateElement(u"audioconvert"_s, u"audioqueueconverter"_s, audiobin_, error);
if (!audioqueueconverter_) {
GstElement *audioqueueconverter = CreateElement(u"audioconvert"_s, u"audioqueueconverter"_s, audiobin_, error);
if (!audioqueueconverter) {
return false;
}
GstElement *audioqueueresampler = CreateElement(u"audioresample"_s, u"audioqueueresampler"_s, audiobin_, error);
if (!audioqueueresampler) {
return false;
}
@@ -645,6 +689,11 @@ bool GstEnginePipeline::InitAudioBin(QString &error) {
return false;
}
GstElement *audiosinkresampler = CreateElement(u"audioresample"_s, u"audiosinkresampler"_s, audiobin_, error);
if (!audiosinkresampler) {
return false;
}
// Create the volume element if it's enabled.
if (volume_enabled_ && !volume_) {
volume_sw_ = CreateElement(u"volume"_s, u"volume_sw"_s, audiobin_, error);
@@ -722,7 +771,8 @@ bool GstEnginePipeline::InitAudioBin(QString &error) {
}
eventprobe_ = audioqueueconverter_;
eventprobe_ = audioqueueconverter;
bufferprobe_ = audioqueueconverter;
// Create the replaygain elements if it's enabled.
GstElement *rgvolume = nullptr;
@@ -808,12 +858,17 @@ bool GstEnginePipeline::InitAudioBin(QString &error) {
// Link all elements
if (!gst_element_link(audioqueue_, audioqueueconverter_)) {
if (!gst_element_link(audioqueue_, audioqueueconverter)) {
error = u"Failed to link audio queue to audio queue converter."_s;
return false;
}
GstElement *element_link = audioqueueconverter_; // The next element to link from.
if (!gst_element_link(audioqueueconverter, audioqueueresampler)) {
error = u"Failed to link audio queue converter to audio queue resampler."_s;
return false;
}
GstElement *element_link = audioqueueresampler; // The next element to link from.
// Link replaygain elements if enabled.
if (rg_enabled_ && rgvolume && rglimiter && rgconverter) {
@@ -889,6 +944,11 @@ bool GstEnginePipeline::InitAudioBin(QString &error) {
return false;
}
if (!gst_element_link(audiosinkconverter, audiosinkresampler)) {
error = "Failed to link audio sink converter to audio sink resampler."_L1;
return false;
}
{
GstCaps *caps = gst_caps_new_empty_simple("audio/x-raw");
if (!caps) {
@@ -899,16 +959,16 @@ bool GstEnginePipeline::InitAudioBin(QString &error) {
qLog(Debug) << "Setting channels to" << channels_;
gst_caps_set_simple(caps, "channels", G_TYPE_INT, channels_, nullptr);
}
const bool link_filtered_result = gst_element_link_filtered(audiosinkconverter, audiosink_, caps);
const bool link_filtered_result = gst_element_link_filtered(audiosinkresampler, audiosink_, caps);
gst_caps_unref(caps);
if (!link_filtered_result) {
error = "Failed to link audio sink converter to audio sink with filter for "_L1 + output_;
error = "Failed to link audio sink resampler to audio sink with filter for "_L1 + output_;
return false;
}
}
{ // Add probes and handlers.
GstPad *pad = gst_element_get_static_pad(audioqueueconverter_, "src");
GstPad *pad = gst_element_get_static_pad(bufferprobe_, "src");
if (pad) {
buffer_probe_cb_id_ = gst_pad_add_probe(pad, GST_PAD_PROBE_TYPE_BUFFER, BufferProbeCallback, this, nullptr);
gst_object_unref(pad);
@@ -1364,6 +1424,13 @@ void GstEnginePipeline::AboutToFinishCallback(GstPlayBin *playbin, gpointer self
GstEnginePipeline *instance = reinterpret_cast<GstEnginePipeline*>(self);
// Ignore about-to-finish if we're in the process of tearing down the pipeline
// This prevents race conditions in GStreamer's decodebin3 when rapidly switching tracks
// See: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/4626
if (instance->finish_requested_.value()) {
return;
}
{
QMutexLocker l(&instance->mutex_url_);
qLog(Debug) << "Stream from URL" << instance->gst_url_ << "about to finish.";
@@ -1740,6 +1807,18 @@ void GstEnginePipeline::BufferingMessageReceived(GstMessage *msg) {
const GstState current_state = state();
if (percent < 100 && !buffering_.value()) {
// If we're near the end of the track and about-to-finish has been signaled, ignore buffering messages to prevent getting stuck in buffering state.
// This can happen with local files where spurious buffering messages appear near the end while the next track is being prepared for gapless playback.
if (about_to_finish_.value()) {
const qint64 current_position = position();
const qint64 track_length = length();
// Ignore buffering if we're within kIgnoreBufferingNearEndSeconds of the end
if (track_length > 0 && current_position > 0 && (track_length - current_position) < kIgnoreBufferingNearEndSeconds * kNsecPerSec) {
qLog(Debug) << "Ignoring buffering message near end of track (position:" << current_position << "length:" << track_length << ")";
return;
}
}
qLog(Debug) << "Buffering started";
buffering_ = true;
Q_EMIT BufferingStarted();
@@ -1841,9 +1920,15 @@ QFuture<GstStateChangeReturn> GstEnginePipeline::SetState(const GstState state)
watcher->deleteLater();
SetStateFinishedSlot(state, state_change_return);
});
QFuture<GstStateChangeReturn> future = QtConcurrent::run(&set_state_threadpool_, &gst_element_set_state, pipeline_, state);
QFuture<GstStateChangeReturn> future = QtConcurrent::run(shared_state_threadpool(), &gst_element_set_state, pipeline_, state);
watcher->setFuture(future);
// Track this future so destructor can wait for it
{
QMutexLocker locker(&mutex_pending_state_changes_);
pending_state_changes_.append(future);
}
return future;
}
@@ -1853,6 +1938,12 @@ void GstEnginePipeline::SetStateFinishedSlot(const GstState state, const GstStat
last_set_state_in_progress_ = GST_STATE_VOID_PENDING;
--set_state_in_progress_;
// Remove finished futures from tracking list to prevent unbounded growth
{
QMutexLocker locker(&mutex_pending_state_changes_);
pending_state_changes_.erase(std::remove_if(pending_state_changes_.begin(), pending_state_changes_.end(), [](const QFuture<GstStateChangeReturn> &f) { return f.isFinished(); }), pending_state_changes_.end());
}
switch (state_change_return) {
case GST_STATE_CHANGE_SUCCESS:
case GST_STATE_CHANGE_ASYNC:

View File

@@ -215,7 +215,8 @@ class GstEnginePipeline : public QObject {
static int sId;
mutex_protected<int> id_;
QThreadPool set_state_threadpool_;
// Shared thread pool for all pipeline state changes to prevent thread/FD exhaustion
static QThreadPool *shared_state_threadpool();
bool playbin3_support_;
bool volume_full_range_support_;
@@ -354,7 +355,6 @@ class GstEnginePipeline : public QObject {
GstElement *audiobin_;
GstElement *audiosink_;
GstElement *audioqueue_;
GstElement *audioqueueconverter_;
GstElement *volume_;
GstElement *volume_sw_;
GstElement *volume_fading_;
@@ -363,6 +363,7 @@ class GstEnginePipeline : public QObject {
GstElement *equalizer_;
GstElement *equalizer_preamp_;
GstElement *eventprobe_;
GstElement *bufferprobe_;
std::optional<gulong> upstream_events_probe_cb_id_;
std::optional<gulong> buffer_probe_cb_id_;
@@ -384,6 +385,10 @@ class GstEnginePipeline : public QObject {
mutex_protected<GstState> last_set_state_in_progress_;
mutex_protected<GstState> last_set_state_async_in_progress_;
// Track futures for this pipeline's state changes to allow waiting for them in destructor
QList<QFuture<GstStateChangeReturn>> pending_state_changes_;
QMutex mutex_pending_state_changes_;
};
using GstEnginePipelinePtr = QSharedPointer<GstEnginePipeline>;

32
src/engine/gsturl.h Normal file
View File

@@ -0,0 +1,32 @@
/*
* Strawberry Music Player
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GSTURL_H
#define GSTURL_H
#include <QByteArray>
#include <QString>
class GstUrl {
public:
QByteArray url;
QString source_device;
};
#endif // GSTURL_H

View File

@@ -32,6 +32,7 @@
#include <QKeySequence>
#include "core/logging.h"
#include "core/settings.h"
#include "globalshortcutsmanager.h"
#include "globalshortcutsbackend.h"
@@ -58,29 +59,32 @@ using namespace Qt::Literals::StringLiterals;
GlobalShortcutsManager::GlobalShortcutsManager(QWidget *parent) : QWidget(parent) {
settings_.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
Settings s;
s.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
// Create actions
AddShortcut(u"play"_s, tr("Play"), std::bind(&GlobalShortcutsManager::Play, this));
AddShortcut(u"pause"_s, tr("Pause"), std::bind(&GlobalShortcutsManager::Pause, this));
AddShortcut(u"play_pause"_s, tr("Play/Pause"), std::bind(&GlobalShortcutsManager::PlayPause, this), QKeySequence(Qt::Key_MediaPlay));
AddShortcut(u"stop"_s, tr("Stop"), std::bind(&GlobalShortcutsManager::Stop, this), QKeySequence(Qt::Key_MediaStop));
AddShortcut(u"stop_after"_s, tr("Stop playing after current track"), std::bind(&GlobalShortcutsManager::StopAfter, this));
AddShortcut(u"next_track"_s, tr("Next track"), std::bind(&GlobalShortcutsManager::Next, this), QKeySequence(Qt::Key_MediaNext));
AddShortcut(u"prev_track"_s, tr("Previous track"), std::bind(&GlobalShortcutsManager::Previous, this), QKeySequence(Qt::Key_MediaPrevious));
AddShortcut(u"restart_or_prev_track"_s, tr("Restart or previous track"), std::bind(&GlobalShortcutsManager::RestartOrPrevious, this));
AddShortcut(u"inc_volume"_s, tr("Increase volume"), std::bind(&GlobalShortcutsManager::IncVolume, this));
AddShortcut(u"dec_volume"_s, tr("Decrease volume"), std::bind(&GlobalShortcutsManager::DecVolume, this));
AddShortcut(u"mute"_s, tr("Mute"), std::bind(&GlobalShortcutsManager::Mute, this));
AddShortcut(u"seek_forward"_s, tr("Seek forward"), std::bind(&GlobalShortcutsManager::SeekForward, this));
AddShortcut(u"seek_backward"_s, tr("Seek backward"), std::bind(&GlobalShortcutsManager::SeekBackward, this));
AddShortcut(u"show_hide"_s, tr("Show/Hide"), std::bind(&GlobalShortcutsManager::ShowHide, this));
AddShortcut(u"show_osd"_s, tr("Show OSD"), std::bind(&GlobalShortcutsManager::ShowOSD, this));
AddShortcut(u"toggle_pretty_osd"_s, tr("Toggle Pretty OSD"), std::bind(&GlobalShortcutsManager::TogglePrettyOSD, this)); // Toggling possible only for pretty OSD
AddShortcut(u"shuffle_mode"_s, tr("Change shuffle mode"), std::bind(&GlobalShortcutsManager::CycleShuffleMode, this));
AddShortcut(u"repeat_mode"_s, tr("Change repeat mode"), std::bind(&GlobalShortcutsManager::CycleRepeatMode, this));
AddShortcut(u"toggle_scrobbling"_s, tr("Enable/disable scrobbling"), std::bind(&GlobalShortcutsManager::ToggleScrobbling, this));
AddShortcut(u"love"_s, tr("Love"), std::bind(&GlobalShortcutsManager::Love, this));
AddShortcut(s, u"play"_s, tr("Play"), std::bind(&GlobalShortcutsManager::Play, this));
AddShortcut(s, u"pause"_s, tr("Pause"), std::bind(&GlobalShortcutsManager::Pause, this));
AddShortcut(s, u"play_pause"_s, tr("Play/Pause"), std::bind(&GlobalShortcutsManager::PlayPause, this), QKeySequence(Qt::Key_MediaPlay));
AddShortcut(s, u"stop"_s, tr("Stop"), std::bind(&GlobalShortcutsManager::Stop, this), QKeySequence(Qt::Key_MediaStop));
AddShortcut(s, u"stop_after"_s, tr("Stop playing after current track"), std::bind(&GlobalShortcutsManager::StopAfter, this));
AddShortcut(s, u"next_track"_s, tr("Next track"), std::bind(&GlobalShortcutsManager::Next, this), QKeySequence(Qt::Key_MediaNext));
AddShortcut(s, u"prev_track"_s, tr("Previous track"), std::bind(&GlobalShortcutsManager::Previous, this), QKeySequence(Qt::Key_MediaPrevious));
AddShortcut(s, u"restart_or_prev_track"_s, tr("Restart or previous track"), std::bind(&GlobalShortcutsManager::RestartOrPrevious, this));
AddShortcut(s, u"inc_volume"_s, tr("Increase volume"), std::bind(&GlobalShortcutsManager::IncVolume, this));
AddShortcut(s, u"dec_volume"_s, tr("Decrease volume"), std::bind(&GlobalShortcutsManager::DecVolume, this));
AddShortcut(s, u"mute"_s, tr("Mute"), std::bind(&GlobalShortcutsManager::Mute, this));
AddShortcut(s, u"seek_forward"_s, tr("Seek forward"), std::bind(&GlobalShortcutsManager::SeekForward, this));
AddShortcut(s, u"seek_backward"_s, tr("Seek backward"), std::bind(&GlobalShortcutsManager::SeekBackward, this));
AddShortcut(s, u"show_hide"_s, tr("Show/Hide"), std::bind(&GlobalShortcutsManager::ShowHide, this));
AddShortcut(s, u"show_osd"_s, tr("Show OSD"), std::bind(&GlobalShortcutsManager::ShowOSD, this));
AddShortcut(s, u"toggle_pretty_osd"_s, tr("Toggle Pretty OSD"), std::bind(&GlobalShortcutsManager::TogglePrettyOSD, this)); // Toggling possible only for pretty OSD
AddShortcut(s, u"shuffle_mode"_s, tr("Change shuffle mode"), std::bind(&GlobalShortcutsManager::CycleShuffleMode, this));
AddShortcut(s, u"repeat_mode"_s, tr("Change repeat mode"), std::bind(&GlobalShortcutsManager::CycleRepeatMode, this));
AddShortcut(s, u"toggle_scrobbling"_s, tr("Enable/disable scrobbling"), std::bind(&GlobalShortcutsManager::ToggleScrobbling, this));
AddShortcut(s, u"love"_s, tr("Love"), std::bind(&GlobalShortcutsManager::Love, this));
s.endGroup();
// Create backends - these do the actual shortcut registration
@@ -116,35 +120,39 @@ void GlobalShortcutsManager::ReloadSettings() {
backends_enabled_ << GlobalShortcutsBackend::Type::Win;
#endif
{
Settings s;
s.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
#ifdef HAVE_KGLOBALACCEL_GLOBALSHORTCUTS
if (settings_.value(GlobalShortcutsSettings::kUseKGlobalAccel, true).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::KGlobalAccel;
}
if (s.value(GlobalShortcutsSettings::kUseKGlobalAccel, true).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::KGlobalAccel;
}
#endif
#ifdef HAVE_X11_GLOBALSHORTCUTS
if (settings_.value(GlobalShortcutsSettings::kUseX11, false).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::X11;
}
if (s.value(GlobalShortcutsSettings::kUseX11, false).toBool()) {
backends_enabled_ << GlobalShortcutsBackend::Type::X11;
}
#endif
s.endGroup();
}
Unregister();
Register();
}
void GlobalShortcutsManager::AddShortcut(const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key) { // clazy:exclude=function-args-by-ref
void GlobalShortcutsManager::AddShortcut(Settings &s, const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key) { // clazy:exclude=function-args-by-ref
Shortcut shortcut = AddShortcut(id, name, default_key);
Shortcut shortcut = AddShortcut(s, id, name, default_key);
QObject::connect(shortcut.action, &QAction::triggered, this, signal);
}
GlobalShortcutsManager::Shortcut GlobalShortcutsManager::AddShortcut(const QString &id, const QString &name, const QKeySequence &default_key) {
GlobalShortcutsManager::Shortcut GlobalShortcutsManager::AddShortcut(Settings &s, const QString &id, const QString &name, const QKeySequence &default_key) {
Shortcut shortcut;
shortcut.action = new QAction(name, this);
QKeySequence key_sequence = QKeySequence::fromString(settings_.value(id, default_key.toString()).toString());
QKeySequence key_sequence = QKeySequence::fromString(s.value(id, default_key.toString()).toString());
shortcut.action->setShortcut(key_sequence);
shortcut.id = id;
shortcut.default_key = default_key;

View File

@@ -32,15 +32,14 @@
#include <QMap>
#include <QString>
#include <QKeySequence>
#include <QSettings>
#include "globalshortcutsbackend.h"
#include "core/settings.h"
class QShortcut;
class QAction;
class Settings;
class GlobalShortcutsManager : public QWidget {
Q_OBJECT
@@ -99,12 +98,11 @@ class GlobalShortcutsManager : public QWidget {
void Love();
private:
void AddShortcut(const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key = QKeySequence(0));
Shortcut AddShortcut(const QString &id, const QString &name, const QKeySequence &default_key);
void AddShortcut(Settings &s, const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key = QKeySequence(0));
Shortcut AddShortcut(Settings &s, const QString &id, const QString &name, const QKeySequence &default_key);
private:
QList<GlobalShortcutsBackend*> backends_;
Settings settings_;
QList<GlobalShortcutsBackend::Type> backends_enabled_;
QMap<QString, Shortcut> shortcuts_;
};

View File

@@ -206,6 +206,15 @@ void Organize::ProcessSomeFiles() {
if (dest_type != Song::FileType::Unknown) {
// Get the preset
TranscoderPreset preset = Transcoder::PresetForFileType(dest_type);
// Check if the destination file already exists and we're not allowed to overwrite
const QString dest_filename_with_new_ext = Utilities::FiddleFileExtension(task.song_info_.new_filename_, preset.extension_);
if (ShouldSkipFile(dest_filename_with_new_ext)) {
qLog(Debug) << "Skipping" << task.song_info_.song_.url().toLocalFile() << ", destination file already exists";
tasks_complete_++;
continue;
}
qLog(Debug) << "Transcoding with" << preset.name_;
task.transcoded_filename_ = transcoder_->GetFile(task.song_info_.song_.url().toLocalFile(), preset);
@@ -222,6 +231,13 @@ void Organize::ProcessSomeFiles() {
}
}
// Check if the destination file already exists and we're not allowed to overwrite
if (ShouldSkipFile(task.song_info_.new_filename_)) {
qLog(Debug) << "Skipping" << task.song_info_.song_.url().toLocalFile() << ", destination file already exists";
tasks_complete_++;
continue;
}
MusicStorage::CopyJob job;
job.source_ = task.transcoded_filename_.isEmpty() ? task.song_info_.song_.url().toLocalFile() : task.transcoded_filename_;
job.destination_ = task.song_info_.new_filename_;
@@ -292,6 +308,16 @@ void Organize::ProcessSomeFiles() {
}
bool Organize::ShouldSkipFile(const QString &filename) const {
if (overwrite_) {
return false;
}
return QFile::exists(destination_->LocalPath() + QLatin1Char('/') + filename);
}
Song::FileType Organize::CheckTranscode(const Song::FileType original_type) const {
if (original_type == Song::FileType::Stream) return Song::FileType::Unknown;

View File

@@ -94,6 +94,7 @@ class Organize : public QObject {
void SetSongProgress(const float progress, const bool transcoded = false);
void UpdateProgress();
Song::FileType CheckTranscode(const Song::FileType original_type) const;
bool ShouldSkipFile(const QString &filename) const;
private:
struct Task {

View File

@@ -1205,7 +1205,7 @@ void Playlist::InsertItemsWithoutUndo(const PlaylistItemPtrList &items, const in
queue_->InsertFirst(indexes);
}
if (auto_sort_) {
if (auto_sort_ && !is_loading_) {
sort(static_cast<int>(sort_column_), sort_order_);
}

View File

@@ -106,8 +106,6 @@ PlaylistContainer::PlaylistContainer(QWidget *parent)
no_matches_font.setBold(true);
no_matches_label_->setFont(no_matches_font);
settings_.beginGroup(kSettingsGroup);
// Tab bar
ui_->tab_bar->setExpanding(false);
ui_->tab_bar->setMovable(true);
@@ -257,7 +255,11 @@ void PlaylistContainer::ReloadSettings() {
ui_->redo->setIconSize(QSize(iconsize, iconsize));
ui_->search_field->setIconSize(iconsize);
bool playlist_clear = settings_.value("playlist_clear", true).toBool();
s.beginGroup(kSettingsGroup);
const bool playlist_clear = s.value("playlist_clear", true).toBool();
const bool show_toolbar = s.value("show_toolbar", true).toBool();
s.endGroup();
if (playlist_clear) {
ui_->clear->show();
}
@@ -265,7 +267,6 @@ void PlaylistContainer::ReloadSettings() {
ui_->clear->hide();
}
bool show_toolbar = settings_.value("show_toolbar", true).toBool();
ui_->toolbar->setVisible(show_toolbar);
if (!show_toolbar) ui_->search_field->clear();
@@ -308,7 +309,12 @@ void PlaylistContainer::PlaylistAdded(const int id, const QString &name, const b
ui_->tab_bar->InsertTab(id, index, name, favorite);
// Are we start up, should we select this tab?
if (starting_up_ && settings_.value("current_playlist", 1).toInt() == id) {
Settings s;
s.beginGroup(kSettingsGroup);
const int current_playlist = s.value("current_playlist", 1).toInt();
s.endGroup();
if (starting_up_ && current_playlist == id) {
starting_up_ = false;
ui_->tab_bar->set_current_id(id);
}
@@ -347,12 +353,14 @@ void PlaylistContainer::NewPlaylist() { manager_->New(tr("Playlist")); }
void PlaylistContainer::LoadPlaylist() {
QString filename = settings_.value("last_load_playlist").toString();
Settings s;
s.beginGroup(kSettingsGroup);
QString filename = s.value("last_load_playlist").toString();
filename = QFileDialog::getOpenFileName(this, tr("Load playlist"), filename, manager_->parser()->filters(PlaylistParser::Type::Load));
if (filename.isNull()) return;
settings_.setValue("last_load_playlist", filename);
s.setValue("last_load_playlist", filename);
manager_->Load(filename);
@@ -391,7 +399,10 @@ void PlaylistContainer::Save() {
if (starting_up_) return;
settings_.setValue("current_playlist", ui_->tab_bar->current_id());
Settings s;
s.beginGroup(kSettingsGroup);
s.setValue("current_playlist", ui_->tab_bar->current_id());
s.endGroup();
}

View File

@@ -28,7 +28,6 @@
#include <QWidget>
#include <QString>
#include <QIcon>
#include <QSettings>
class QTimer;
class QTimeLine;
@@ -45,7 +44,6 @@ class PlaylistView;
class Ui_PlaylistContainer;
#include "includes/shared_ptr.h"
#include "core/settings.h"
class PlaylistContainer : public QWidget {
Q_OBJECT
@@ -118,7 +116,6 @@ class PlaylistContainer : public QWidget {
QAction *redo_;
Playlist *playlist_;
Settings settings_;
bool starting_up_;
bool tab_bar_visible_;

View File

@@ -57,7 +57,6 @@
#include <QLinearGradient>
#include <QScrollBar>
#include <QtEvents>
#include <QSettings>
#include <QDrag>
#include "includes/qt_blurimage.h"

View File

@@ -80,12 +80,13 @@ void SongLoaderInserter::Load(Playlist *destination, const int row, const bool p
songs_ << loader->songs();
playlist_name_ = loader->playlist_name();
}
else {
const QStringList errors = loader->errors();
for (const QString &error : errors) {
Q_EMIT Error(error);
}
// Always check for errors, even on success (e.g., playlist parsed but some songs failed to load)
const QStringList errors = loader->errors();
for (const QString &error : errors) {
Q_EMIT Error(error);
}
delete loader;
}
@@ -192,11 +193,13 @@ void SongLoaderInserter::AsyncLoad() {
const SongLoader::Result result = loader->LoadFilenamesBlocking();
task_manager_->SetTaskProgress(async_load_id, static_cast<quint64>(++async_progress));
// Always check for errors, even on success (e.g., playlist parsed but some songs failed to load)
const QStringList errors = loader->errors();
for (const QString &error : errors) {
Q_EMIT Error(error);
}
if (result == SongLoader::Result::Error) {
const QStringList errors = loader->errors();
for (const QString &error : errors) {
Q_EMIT Error(error);
}
continue;
}

View File

@@ -112,10 +112,18 @@ void ParserBase::LoadSong(const QString &filename_or_url, const qint64 beginning
}
}
// Check if the file exists before trying to read it
if (!QFile::exists(filename)) {
qLog(Error) << "File does not exist:" << filename;
Q_EMIT Error(tr("File %1 does not exist").arg(filename));
return;
}
if (tagreader_client_) {
const TagReaderResult result = tagreader_client_->ReadFileBlocking(filename, song);
if (!result.success()) {
qLog(Error) << "Could not read file" << filename << result.error_string();
Q_EMIT Error(tr("Could not read file %1: %2").arg(filename, result.error_string()));
}
}

View File

@@ -0,0 +1,116 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QString>
#include <QUrl>
#include <QClipboard>
#include <QMessageBox>
#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;
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ADDPODCASTBYURL_H
#define ADDPODCASTBYURL_H
#include <QObject>
#include <QUrl>
#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

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>AddPodcastByUrl</class>
<widget class="QWidget" name="AddPodcastByUrl">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>431</width>
<height>51</height>
</rect>
</property>
<property name="windowTitle">
<string>Enter a URL</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>If you know the URL of a podcast, enter it below and press Go.</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="url"/>
</item>
<item>
<widget class="QPushButton" name="go">
<property name="text">
<string>Go</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<connections>
<connection>
<sender>url</sender>
<signal>returnPressed()</signal>
<receiver>go</receiver>
<slot>click()</slot>
<hints>
<hint type="sourcelabel">
<x>109</x>
<y>24</y>
</hint>
<hint type="destinationlabel">
<x>429</x>
<y>49</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,270 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QDialog>
#include <QVariant>
#include <QString>
#include <QUrl>
#include <QDir>
#include <QFileDialog>
#include <QTimer>
#include <QPushButton>
#include <QListWidget>
#include <QItemSelectionModel>
#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 &current) {
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<Podcast>();
// 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<AddPodcastPage*>(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(&current_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<Podcast>();
app_->podcast_backend()->Subscribe(&current_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_));
}

View File

@@ -0,0 +1,91 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ADDPODCASTDIALOG_H
#define ADDPODCASTDIALOG_H
#include <QDialog>
#include <QList>
#include <QString>
#include <QUrl>
#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 &current);
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<AddPodcastPage*> pages_;
QList<bool> page_is_busy_;
AddPodcastByUrl *by_url_page_;
WidgetFadeHelper *fader_;
Podcast current_podcast_;
QString last_opml_path_;
};
#endif // ADDPODCASTDIALOG_H

View File

@@ -0,0 +1,276 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>AddPodcastDialog</class>
<widget class="QDialog" name="AddPodcastDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>941</width>
<height>473</height>
</rect>
</property>
<property name="windowTitle">
<string>Add podcast</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QListWidget" name="provider_list">
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="iconSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="uniformItemSizes">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QStackedWidget" name="stack">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<widget class="QStackedWidget" name="results_stack">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="results_page">
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="AutoExpandingTreeView" name="results">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="uniformRowHeights">
<bool>true</bool>
</property>
<attribute name="headerVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="busy_page">
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>192</height>
</size>
</property>
</spacer>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="BusyIndicator" name="widget" native="true">
<property name="text" stdset="0">
<string>Loading...</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QScrollArea" name="details_scroll_area">
<property name="minimumSize">
<size>
<width>250</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>250</width>
<height>16777215</height>
</size>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="PodcastInfoWidget" name="details">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>248</width>
<height>415</height>
</rect>
</property>
</widget>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QDialogButtonBox" name="button_box">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BusyIndicator</class>
<extends>QWidget</extends>
<header>widgets/busyindicator.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>PodcastInfoWidget</class>
<extends>QWidget</extends>
<header>podcasts/podcastinfowidget.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>AutoExpandingTreeView</class>
<extends>QTreeView</extends>
<header>widgets/autoexpandingtreeview.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>button_box</sender>
<signal>accepted()</signal>
<receiver>AddPodcastDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>836</x>
<y>463</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>button_box</sender>
<signal>rejected()</signal>
<receiver>AddPodcastDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>885</x>
<y>463</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,36 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QWidget>
#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;
}

View File

@@ -0,0 +1,53 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ADDPODCASTPAGE_H
#define ADDPODCASTPAGE_H
#include <QWidget>
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

View File

@@ -0,0 +1,49 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QWidget>
#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()));
}

View File

@@ -0,0 +1,50 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef EPISODEINFOWIDGET_H
#define EPISODEINFOWIDGET_H
#include <QWidget>
#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

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>EpisodeInfoWidget</class>
<widget class="QWidget" name="EpisodeInfoWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>398</width>
<height>551</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<property name="styleSheet">
<string notr="true">#title {
font-weight: bold;
}
#description {
font-size: smaller;
}
QLineEdit {
background: transparent;
}</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item>
<widget class="QLabel" name="title">
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="description">
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="author_label">
<property name="text">
<string>Author</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="date">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="author">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="date_label">
<property name="text">
<string>Date</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="duration_label">
<property name="text">
<string>Duration</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="duration">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,82 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QString>
#include <QUrl>
#include <QIcon>
#include <QMessageBox>
#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;
}
}

View File

@@ -0,0 +1,56 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef FIXEDOPMLPAGE_H
#define FIXEDOPMLPAGE_H
#include <QObject>
#include <QUrl>
#include <QIcon>
#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

View File

@@ -0,0 +1,100 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QMessageBox>
#include <QPushButton>
#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(); }

View File

@@ -0,0 +1,57 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERSEARCHPAGE_H
#define GPODDERSEARCHPAGE_H
#include <ApiRequest.h>
#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

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>GPodderSearchPage</class>
<widget class="QWidget" name="GPodderSearchPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>538</width>
<height>69</height>
</rect>
</property>
<property name="windowTitle">
<string>Search gpodder.net</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Enter search terms below to find podcasts on gpodder.net</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="query"/>
</item>
<item>
<widget class="QPushButton" name="search">
<property name="text">
<string>Search</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<connections>
<connection>
<sender>query</sender>
<signal>returnPressed()</signal>
<receiver>search</receiver>
<slot>click()</slot>
<hints>
<hint type="sourcelabel">
<x>130</x>
<y>45</y>
</hint>
<hint type="destinationlabel">
<x>198</x>
<y>46</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,415 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QCoreApplication>
#include <QSet>
#include <QList>
#include <QMap>
#include <QString>
#include <QUrl>
#include <QHostInfo>
#include <QNetworkReply>
#include <QNetworkCookieJar>
#include <QSettings>
#include <QTimer>
#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<QUrl, QList<mygpo::EpisodePtr>> 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<mygpo::EpisodePtr> &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<QSharedPointer<mygpo::Episode>> &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<typename T>
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<typename T>
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<typename T::value_type>();
}
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<QUrl> 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<QUrl> &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();
}

125
src/podcasts/gpoddersync.h Normal file
View File

@@ -0,0 +1,125 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERSYNC_H
#define GPODDERSYNC_H
#include <QObject>
#include <QScopedPointer>
#include <QSet>
#include <QList>
#include <QString>
#include <QDateTime>
#include <QUrl>
#include <QNetworkReply>
#include <ApiRequest.h>
#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<mygpo::EpisodePtr> &actions);
void ApplyActions(const QList<mygpo::EpisodePtr> &actions, PodcastEpisodeList *episodes);
void SubscriptionAdded(const Podcast &podcast);
void SubscriptionRemoved(const Podcast &podcast);
void FlushUpdateQueue();
void AddRemoveFinished(const QList<QUrl> &affected_urls);
void AddRemoveParseError();
void AddRemoveRequestError(QNetworkReply::NetworkError error);
private:
void LoadQueue();
void SaveQueue();
void DoInitialSync();
private:
Application *app_;
NetworkAccessManager *network_;
QScopedPointer<mygpo::ApiRequest> api_;
PodcastBackend *backend_;
PodcastUrlLoader *loader_;
QString username_;
QString password_;
QDateTime last_successful_get_;
QTimer *get_updates_timer_;
QTimer *flush_queue_timer_;
QSet<QUrl> queued_add_subscriptions_;
QSet<QUrl> queued_remove_subscriptions_;
bool flushing_queue_;
};
#endif // GPODDERSYNC_H

View File

@@ -0,0 +1,115 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QMessageBox>
#include <ApiRequest.h>
#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);
}

View File

@@ -0,0 +1,61 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERTOPTAGSMODEL_H
#define GPODDERTOPTAGSMODEL_H
#include <QObject>
#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

View File

@@ -0,0 +1,93 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QMessageBox>
#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();
}

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERTOPTAGSPAGE_H
#define GPODDERTOPTAGSPAGE_H
#include <QObject>
#include <ApiRequest.h>
#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

View File

@@ -0,0 +1,133 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QUrl>
#include <QUrlQuery>
#include <QMessageBox>
#include <QPushButton>
#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(); }

View File

@@ -0,0 +1,56 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef 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

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ITunesSearchPage</class>
<widget class="QWidget" name="ITunesSearchPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>516</width>
<height>69</height>
</rect>
</property>
<property name="windowTitle">
<string>Search iTunes</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="margin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>Enter search terms below to find podcasts in the iTunes Store</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="query"/>
</item>
<item>
<widget class="QPushButton" name="search">
<property name="text">
<string>Search</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<connections>
<connection>
<sender>query</sender>
<signal>returnPressed()</signal>
<receiver>search</receiver>
<slot>click()</slot>
<hints>
<hint type="sourcelabel">
<x>237</x>
<y>52</y>
</hint>
<hint type="destinationlabel">
<x>461</x>
<y>55</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,45 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef OPMLCONTAINER_H
#define OPMLCONTAINER_H
#include <QList>
#include <QString>
#include <QUrl>
#include "podcast.h"
class OpmlContainer {
public:
// Only set for the top-level container
QUrl url;
QString name;
QList<OpmlContainer> containers;
PodcastList feeds;
};
Q_DECLARE_METATYPE(OpmlContainer)
#endif // OPMLCONTAINER_H

194
src/podcasts/podcast.cpp Normal file
View File

@@ -0,0 +1,194 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QSharedData>
#include <QVariant>
#include <QVariantMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDataStream>
#include <QDateTime>
#include <QSqlQuery>
#include "core/utilities.h"
#include "podcast.h"
#include <Podcast.h>
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());
}

114
src/podcasts/podcast.h Normal file
View File

@@ -0,0 +1,114 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCAST_H
#define PODCAST_H
#include <QSharedData>
#include <QSharedDataPointer>
#include <QVariant>
#include <QVariantMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QSqlQuery>
#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 &copyright() 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<Private> d;
};
Q_DECLARE_METATYPE(Podcast)
typedef QList<Podcast> PodcastList;
Q_DECLARE_METATYPE(QList<Podcast>)
#endif // PODCAST_H

View File

@@ -0,0 +1,368 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QMutexLocker>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QUrl>
#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;
}

View File

@@ -0,0 +1,98 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTBACKEND_H
#define PODCASTBACKEND_H
#include <QObject>
#include <QDateTime>
#include <QUrl>
#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

View File

@@ -0,0 +1,124 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QFile>
#include <QDateTime>
#include <QTimer>
#include <QSettings>
#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();
}

View File

@@ -0,0 +1,56 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTDELETER_H
#define PODCASTDELETER_H
#include <QObject>
#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

View File

@@ -0,0 +1,125 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "podcastdiscoverymodel.h"
#include <QStandardItemModel>
#include <QStandardItem>
#include <QSet>
#include <QString>
#include <QUrl>
#include <QIcon>
#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<PodcastDiscoveryModel*>(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;
}

View File

@@ -0,0 +1,79 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTDISCOVERYMODEL_H
#define PODCASTDISCOVERYMODEL_H
#include <QStandardItemModel>
#include <QString>
#include <QUrl>
#include <QIcon>
#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

View File

@@ -0,0 +1,288 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "podcastdownloader.h"
#include <QString>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QList>
#include <QString>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QSettings>
#include <QTimer>
#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<float>(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<Task*> 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);
}
}

View File

@@ -0,0 +1,129 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTDOWNLOADER_H
#define PODCASTDOWNLOADER_H
#include <memory>
#include <QObject>
#include <QFile>
#include <QSet>
#include <QList>
#include <QQueue>
#include <QString>
#include <QRegularExpression>
#include <QNetworkRequest>
#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<QFile> file_;
PodcastEpisode episode_;
PodcastBackend *backend_;
std::unique_ptr<NetworkAccessManager> 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<Task*> list_tasks_;
};
#endif // PODCASTDOWNLOADER_H

View File

@@ -0,0 +1,231 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <limits>
#include <QSharedData>
#include <QIODevice>
#include <QFile>
#include <QFileInfo>
#include <QDataStream>
#include <QVariant>
#include <QVariantMap>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDateTime>
#include <QSqlQuery>
#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<unsigned int>::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<unsigned int>::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;
}

View File

@@ -0,0 +1,100 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTEPISODE_H
#define PODCASTEPISODE_H
#include <QSharedDataPointer>
#include <QVariant>
#include <QVariantMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDateTime>
#include <QSqlQuery>
#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<Private> d;
};
Q_DECLARE_METATYPE(PodcastEpisode)
typedef QList<PodcastEpisode> PodcastEpisodeList;
Q_DECLARE_METATYPE(QList<PodcastEpisode>)
#endif // PODCASTEPISODE_H

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QDialog>
#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();
}

View File

@@ -0,0 +1,48 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTINFODIALOG_H
#define PODCASTINFODIALOG_H
#include <QDialog>
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

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PodcastInfoDialog</class>
<widget class="QDialog" name="PodcastInfoDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>493</width>
<height>415</height>
</rect>
</property>
<property name="windowTitle">
<string>Podcast Information</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QLineEdit" name="podcast_url"/>
</item>
<item>
<widget class="QScrollArea" name="episode_info_scroll_area">
<property name="enabled">
<bool>true</bool>
</property>
<property name="minimumSize">
<size>
<width>250</width>
<height>100</height>
</size>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="EpisodeInfoWidget" name="episode_details">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>473</width>
<height>158</height>
</rect>
</property>
</widget>
</widget>
</item>
<item>
<widget class="QScrollArea" name="podcast_info_scroll_area">
<property name="minimumSize">
<size>
<width>250</width>
<height>100</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>16777215</height>
</size>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="PodcastInfoWidget" name="podcast_details">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>473</width>
<height>157</height>
</rect>
</property>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::Close</set>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>PodcastInfoWidget</class>
<extends>QWidget</extends>
<header>podcasts/podcastinfowidget.h</header>
<container>1</container>
</customwidget>
<customwidget>
<class>EpisodeInfoWidget</class>
<extends>QWidget</extends>
<header location="global">podcasts/episodeinfowidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>PodcastInfoDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>PodcastInfoDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,134 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QWidget>
#include <QImage>
#include <QPixmap>
#include <QColor>
#include <QPalette>
#include <QLabel>
#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<QLabel*>()) {
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<typename T>
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();
}

View File

@@ -0,0 +1,65 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTINFOWIDGET_H
#define PODCASTINFOWIDGET_H
#include <QWidget>
#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

View File

@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PodcastInfoWidget</class>
<widget class="QWidget" name="PodcastInfoWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>398</width>
<height>551</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<property name="styleSheet">
<string notr="true">#title {
font-weight: bold;
}
#description {
font-size: smaller;
}
QLineEdit {
background: transparent;
}</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>0</number>
</property>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="image">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="title">
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="description">
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse</set>
</property>
</widget>
</item>
<item>
<layout class="QGridLayout" name="gridLayout">
<property name="sizeConstraint">
<enum>QLayout::SetMinAndMaxSize</enum>
</property>
<item row="4" column="1">
<widget class="QLineEdit" name="website">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="author_label">
<property name="text">
<string>Author</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="owner">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="website_label">
<property name="text">
<string>Website</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="copyright">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="copyright_label">
<property name="text">
<string>Copyright</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="author">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="owner_label">
<property name="text">
<string>Owner</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="subscribers_label">
<property name="text">
<string>Subscribers</string>
</property>
<property name="field_label" stdset="0">
<bool>true</bool>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="subscribers">
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,376 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QDateTime>
#include <QXmlStreamReader>
#include <QRegularExpression>
#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("<rss\\b")) || str.contains(QRegularExpression("<opml\\b"));
}
QVariant PodcastParser::Load(QIODevice *device, const QUrl &url) const {
QXmlStreamReader reader(device);
while (!reader.atEnd()) {
switch (reader.readNext()) {
case QXmlStreamReader::StartElement: {
const QString name = reader.name().toString();
if (name == "rss") {
Podcast podcast;
if (!ParseRss(&reader, &podcast)) {
return QVariant();
}
else {
podcast.set_url(url);
return QVariant::fromValue(podcast);
}
}
else if (name == "opml") {
OpmlContainer container;
if (!ParseOpml(&reader, &container)) {
return QVariant();
}
else {
container.url = url;
return QVariant::fromValue(container);
}
}
return QVariant();
}
default:
break;
}
}
return QVariant();
}
bool PodcastParser::ParseRss(QXmlStreamReader *reader, Podcast *ret) const {
if (!Utilities::ParseUntilElement(reader, "channel")) {
return false;
}
ParseChannel(reader, ret);
return true;
}
void PodcastParser::ParseChannel(QXmlStreamReader *reader, Podcast *ret) const {
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") {
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;
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTPARSER_H
#define PODCASTPARSER_H
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#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

View File

@@ -0,0 +1,919 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012-2013, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2013-2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2014, Simeon Bird <sbird@andrew.cmu.edu>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <memory>
#include "podcastservice.h"
#include <QObject>
#include <QtConcurrentRun>
#include <QSet>
#include <QMap>
#include <QVariant>
#include <QString>
#include <QIcon>
#include <QDateTime>
#include <QFont>
#include <QStandardItemModel>
#include <QStandardItem>
#include <QSortFilterProxyModel>
#include <QMenu>
#include <QAction>
#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<PodcastEpisode>();
const PodcastEpisode right_episode = right.data(PodcastService::Role_Episode).value<PodcastEpisode>();
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<PodcastEpisode>();
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<PodcastEpisode>();
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<PodcastEpisode>();
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<PodcastEpisode>();
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<Podcast>();
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<PodcastEpisode>();
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<Podcast>();
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<int> 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<Podcast>().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<Podcast>().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<PodcastEpisode>();
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<Podcast>());
}
}
void PodcastService::RemoveSelectedPodcast() {
for (const QModelIndex &index : selected_podcasts_) {
backend_->Unsubscribe(index.data(Role_Podcast).value<Podcast>());
}
}
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<PodcastEpisode>().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<int> 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<Podcast>();
ReloadPodcast(podcast);
}
}
void PodcastService::EpisodesUpdated(const PodcastEpisodeList &episodes) {
QSet<int> seen_podcast_ids;
QMap<int, Podcast> 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<Podcast>();
podcasts_map[podcast.database_id()] = podcast;
}
QList<Podcast> 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<PodcastEpisode>());
}
}
void PodcastService::PodcastInfo() {
if (selected_podcasts_.isEmpty()) {
// Should never happen.
return;
}
const Podcast podcast = selected_podcasts_[0].data(Role_Podcast).value<Podcast>();
podcast_info_dialog_.reset(new PodcastInfoDialog(app_));
if (selected_episodes_.count() == 1) {
const PodcastEpisode episode = selected_episodes_[0].data(Role_Episode).value<PodcastEpisode>();
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<PodcastEpisode>());
}
}
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<PodcastEpisode>();
}
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<PodcastEpisode>();
}
}
// 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(podcast_or_opml.value<Podcast>());
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<OpmlContainer>()) {
EnsureAddPodcastDialogCreated();
add_podcast_dialog_->ShowWithOpml(podcast_or_opml.value<OpmlContainer>());
}
}
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));
}

View File

@@ -0,0 +1,178 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012-2013, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2013-2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2014, Simeon Bird <sbird@andrew.cmu.edu>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTSERVICE_H
#define PODCASTSERVICE_H
#include <memory>
#include <QMap>
#include <QIcon>
#include <QScopedPointer>
//#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<OrganizeDialog> organize_dialog_;
QModelIndexList explicitly_selected_podcasts_;
QModelIndexList selected_podcasts_;
QModelIndexList selected_episodes_;
QMap<int, QStandardItem*> podcasts_by_database_id_;
QMap<int, QStandardItem*> episodes_by_database_id_;
std::unique_ptr<AddPodcastDialog> add_podcast_dialog_;
std::unique_ptr<PodcastInfoDialog> podcast_info_dialog_;
};
#endif // PODCASTSERVICE_H

View File

@@ -0,0 +1,101 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QList>
#include <QVariant>
#include <QUrl>
#include <QMimeData>
#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<QUrl> 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<QUrl>* urls) const {
QVariant episode_variant = idx.data(PodcastService::Role_Episode);
if (!episode_variant.isValid()) return;
PodcastEpisode episode(episode_variant.value<PodcastEpisode>());
// 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<Podcast>();
}
Song song = episode.ToSong(podcast);
data->songs << song;
*urls << song.url();
}
void PodcastServiceModel::MimeDataForPodcast(const QModelIndex &idx, SongMimeData *data, QList<QUrl> *urls) const {
// Get the podcast
Podcast podcast;
QVariant podcast_variant = idx.data(PodcastService::Role_Podcast);
if (podcast_variant.isValid()) {
podcast = podcast_variant.value<Podcast>();
}
// 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<PodcastEpisode>());
Song song = episode.ToSong(podcast);
data->songs << song;
*urls << song.url();
}
}

View File

@@ -0,0 +1,46 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTSERVICEMODEL_H
#define PODCASTSERVICEMODEL_H
#include <QStandardItemModel>
#include <QList>
#include <QUrl>
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<QUrl> *urls) const;
void MimeDataForEpisode(const QModelIndex &idx, SongMimeData* data, QList<QUrl> *urls) const;
};
#endif // PODCASTSERVICEMODEL_H

View File

@@ -0,0 +1,194 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "podcastupdater.h"
#include <QObject>
#include <QSet>
#include <QList>
#include <QUrl>
#include <QDateTime>
#include <QTimer>
#include <QSettings>
#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<QUrl> 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();
}

View File

@@ -0,0 +1,71 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTUPDATER_H
#define PODCASTUPDATER_H
#include <QObject>
#include <QDateTime>
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

View File

@@ -0,0 +1,250 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#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_("<link (.*)>"),
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<Podcast>()) {
state->reply_->SetFinished(PodcastList() << ret.value<Podcast>());
}
else if (ret.canConvert<OpmlContainer>()) {
state->reply_->SetFinished(ret.value<OpmlContainer>());
}
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);
}

View File

@@ -0,0 +1,119 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTURLLOADER_H
#define PODCASTURLLOADER_H
#include <QObject>
#include <QRegularExpression>
#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<QString, QString> QuickPrefix;
typedef QList<QuickPrefix> 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

View File

@@ -0,0 +1,277 @@
/*
* Strawberry Music Player
* Copyright 2019-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "config.h"
#include <QByteArray>
#include <QMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include "includes/shared_ptr.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "qobuzcredentialfetcher.h"
using namespace Qt::Literals::StringLiterals;
namespace {
constexpr char kLoginPageUrl[] = "https://play.qobuz.com/login";
constexpr char kPlayQobuzUrl[] = "https://play.qobuz.com";
} // namespace
QobuzCredentialFetcher::QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: QObject(parent),
network_(network),
login_page_reply_(nullptr),
bundle_reply_(nullptr) {}
void QobuzCredentialFetcher::FetchCredentials() {
qLog(Debug) << "Qobuz: Fetching credentials from web player";
QNetworkRequest request(QUrl(QString::fromLatin1(kLoginPageUrl)));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
login_page_reply_ = network_->get(request);
QObject::connect(login_page_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::LoginPageReceived);
}
void QobuzCredentialFetcher::LoginPageReceived() {
if (!login_page_reply_) return;
QNetworkReply *reply = login_page_reply_;
login_page_reply_ = nullptr;
if (reply->error() != QNetworkReply::NoError) {
QString error = QStringLiteral("Failed to fetch login page: %1").arg(reply->errorString());
qLog(Error) << "Qobuz:" << error;
reply->deleteLater();
Q_EMIT CredentialsFetchError(error);
return;
}
const QString login_page = QString::fromUtf8(reply->readAll());
reply->deleteLater();
// Extract bundle.js URL from the login page
// Pattern: <script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>
static const QRegularExpression bundle_url_regex(u"<script src=\"(/resources/[\\d.]+-[a-z]\\d+/bundle\\.js)\"></script>"_s);
const QRegularExpressionMatch bundle_match = bundle_url_regex.match(login_page);
if (!bundle_match.hasMatch()) {
QString error = u"Failed to find bundle.js URL in login page"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
bundle_url_ = bundle_match.captured(1);
qLog(Debug) << "Qobuz: Found bundle URL:" << bundle_url_;
// Fetch the bundle.js
QNetworkRequest request(QUrl(QString::fromLatin1(kPlayQobuzUrl) + bundle_url_));
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
bundle_reply_ = network_->get(request);
QObject::connect(bundle_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::BundleReceived);
}
void QobuzCredentialFetcher::BundleReceived() {
if (!bundle_reply_) return;
QNetworkReply *reply = bundle_reply_;
bundle_reply_ = nullptr;
if (reply->error() != QNetworkReply::NoError) {
QString error = QStringLiteral("Failed to fetch bundle.js: %1").arg(reply->errorString());
qLog(Error) << "Qobuz:" << error;
reply->deleteLater();
Q_EMIT CredentialsFetchError(error);
return;
}
const QString bundle = QString::fromUtf8(reply->readAll());
reply->deleteLater();
qLog(Debug) << "Qobuz: Bundle size:" << bundle.length();
const QString app_id = ExtractAppId(bundle);
if (app_id.isEmpty()) {
QString error = u"Failed to extract app_id from bundle"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
const QString app_secret = ExtractAppSecret(bundle);
if (app_secret.isEmpty()) {
QString error = u"Failed to extract app_secret from bundle"_s;
qLog(Error) << "Qobuz:" << error;
Q_EMIT CredentialsFetchError(error);
return;
}
qLog(Debug) << "Qobuz: Successfully extracted credentials - app_id:" << app_id;
Q_EMIT CredentialsFetched(app_id, app_secret);
}
QString QobuzCredentialFetcher::ExtractAppId(const QString &bundle) {
// Pattern: production:{api:{appId:"(\d+)"
static const QRegularExpression app_id_regex(u"production:\\{api:\\{appId:\"(\\d+)\""_s);
const QRegularExpressionMatch app_id_match = app_id_regex.match(bundle);
if (app_id_match.hasMatch()) {
return app_id_match.captured(1);
}
return QString();
}
QString QobuzCredentialFetcher::ExtractAppSecret(const QString &bundle) {
// The plain-text appSecret in the bundle doesn't work for API requests.
// We need to use the Spoofbuz method to extract the real secrets:
// 1. Find seed/timezone pairs
// 2. Find info/extras for each timezone
// 3. Combine seed + info + extras, remove last 44 chars, base64 decode
// Pattern to find seed and timezone: [a-z].initialSeed("seed",window.utimezone.timezone)
static const QRegularExpression seed_regex(u"[a-z]\\.initialSeed\\(\"([\\w=]+)\",window\\.utimezone\\.([a-z]+)\\)"_s);
QMap<QString, QString> seeds; // timezone -> seed
QRegularExpressionMatchIterator seed_iter = seed_regex.globalMatch(bundle);
while (seed_iter.hasNext()) {
const QRegularExpressionMatch seed_match = seed_iter.next();
const QString seed = seed_match.captured(1);
const QString tz = seed_match.captured(2);
seeds[tz] = seed;
qLog(Debug) << "Qobuz: Found seed for timezone" << tz;
}
if (seeds.isEmpty()) {
qLog(Error) << "Qobuz: No seed/timezone pairs found in bundle";
return QString();
}
// Try each timezone - Berlin was confirmed working
const QStringList preferred_order = {u"berlin"_s, u"london"_s, u"abidjan"_s};
for (const QString &tz : preferred_order) {
if (!seeds.contains(tz)) {
continue;
}
// Pattern to find info and extras for this timezone
// name:"xxx/Berlin",info:"...",extras:"..."
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
const QRegularExpression info_regex(info_pattern);
const QRegularExpressionMatch info_match = info_regex.match(bundle);
if (!info_match.hasMatch()) {
qLog(Debug) << "Qobuz: No info/extras found for timezone" << tz;
continue;
}
const QString seed = seeds[tz];
const QString info = info_match.captured(1);
const QString extras = info_match.captured(2);
qLog(Debug) << "Qobuz: Decoding secret for timezone" << tz;
// Combine seed + info + extras
const QString combined = seed + info + extras;
// Remove last 44 characters
if (combined.length() <= 44) {
qLog(Debug) << "Qobuz: Combined string too short for timezone" << tz;
continue;
}
const QString trimmed = combined.left(combined.length() - 44);
// Base64 decode
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
const QString secret = QString::fromLatin1(decoded);
// Validate: should be 32 hex characters
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
if (hex_regex.match(secret).hasMatch()) {
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
return secret;
}
qLog(Debug) << "Qobuz: Decoded secret invalid for timezone" << tz;
}
// Try any remaining timezones not in preferred order
for (auto it = seeds.constBegin(); it != seeds.constEnd(); ++it) {
const QString &tz = it.key();
if (preferred_order.contains(tz)) {
continue; // Already tried
}
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
const QRegularExpression info_regex(info_pattern);
const QRegularExpressionMatch info_match = info_regex.match(bundle);
if (!info_match.hasMatch()) {
continue;
}
const QString seed = it.value();
const QString info = info_match.captured(1);
const QString extras = info_match.captured(2);
const QString combined = seed + info + extras;
if (combined.length() <= 44) {
continue;
}
const QString trimmed = combined.left(combined.length() - 44);
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
const QString secret = QString::fromLatin1(decoded);
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
if (hex_regex.match(secret).hasMatch()) {
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
return secret;
}
}
qLog(Error) << "Qobuz: Failed to decode any valid app_secret from bundle";
return QString();
}

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* Copyright 2019-2025, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef QOBUZCREDENTIALFETCHER_H
#define QOBUZCREDENTIALFETCHER_H
#include "config.h"
#include <QObject>
#include <QString>
#include "includes/shared_ptr.h"
class QNetworkReply;
class NetworkAccessManager;
class QobuzCredentialFetcher : public QObject {
Q_OBJECT
public:
explicit QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
void FetchCredentials();
Q_SIGNALS:
void CredentialsFetched(const QString &app_id, const QString &app_secret);
void CredentialsFetchError(const QString &error);
private Q_SLOTS:
void LoginPageReceived();
void BundleReceived();
private:
QString ExtractAppId(const QString &bundle);
QString ExtractAppSecret(const QString &bundle);
const SharedPtr<NetworkAccessManager> network_;
QNetworkReply *login_page_reply_;
QNetworkReply *bundle_reply_;
QString bundle_url_;
};
#endif // QOBUZCREDENTIALFETCHER_H

View File

@@ -310,6 +310,10 @@ void QobuzService::HandleLoginSSLErrors(const QList<QSslError> &ssl_errors) {
void QobuzService::HandleAuthReply(QNetworkReply *reply) {
if (replies_.contains(reply)) {
replies_.removeAll(reply);
}
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
login_sent_ = false;

View File

@@ -106,6 +106,8 @@ class QobuzService : public StreamingService {
bool login_sent() const { return login_sent_; }
bool login_attempts() const { return login_attempts_; }
SharedPtr<NetworkAccessManager> network() const { return network_; }
uint GetStreamURL(const QUrl &url, QString &error);
SharedPtr<CollectionBackend> artists_collection_backend() override { return artists_collection_backend_; }

View File

@@ -115,6 +115,7 @@ void QobuzStreamURLRequest::GetStreamURL() {
const quint64 timestamp = static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
ParamList params_to_sign = ParamList() << Param(u"format_id"_s, QString::number(service_->format()))
<< Param(u"intent"_s, u"stream"_s)
<< Param(u"track_id"_s, QString::number(song_id_));
std::sort(params_to_sign.begin(), params_to_sign.end());

View File

@@ -0,0 +1,146 @@
/* This file is part of Clementine.
Copyright 2012, David Sansome <me@davidsansome.com>
Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
Copyright 2014, John Maguire <john.maguire@gmail.com>
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 <http://www.gnu.org/licenses/>.
*/
#include "podcastsettingspage.h"
#include <QFileDialog>
#include <QSettings>
#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));
}

View File

@@ -0,0 +1,52 @@
/* This file is part of Clementine.
Copyright 2012, David Sansome <me@davidsansome.com>
Copyright 2014, John Maguire <john.maguire@gmail.com>
Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
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 <http://www.gnu.org/licenses/>.
*/
#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

View File

@@ -0,0 +1,290 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PodcastSettingsPage</class>
<widget class="QWidget" name="PodcastSettingsPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>616</width>
<height>656</height>
</rect>
</property>
<property name="windowTitle">
<string>Podcasts</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Updating</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Check for new episodes</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="check_interval">
<item>
<property name="text">
<string>Manually</string>
</property>
</item>
<item>
<property name="text">
<string>Every 10 minutes</string>
</property>
</item>
<item>
<property name="text">
<string>Every 20 minutes</string>
</property>
</item>
<item>
<property name="text">
<string>Every 30 minutes</string>
</property>
</item>
<item>
<property name="text">
<string>Every hour</string>
</property>
</item>
<item>
<property name="text">
<string>Every 2 hours</string>
</property>
</item>
<item>
<property name="text">
<string>Every 6 hours</string>
</property>
</item>
<item>
<property name="text">
<string>Every 12 hours</string>
</property>
</item>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Download episodes to</string>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QCheckBox" name="auto_download">
<property name="text">
<string>Download new episodes automatically</string>
</property>
</widget>
</item>
<item row="1" column="1">
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLineEdit" name="download_dir"/>
</item>
<item>
<widget class="QPushButton" name="download_dir_browse">
<property name="text">
<string>Browse...</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Cleaning up</string>
</property>
<layout class="QFormLayout" name="formLayout_2">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Delete played episodes</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="delete_after">
<property name="specialValueText">
<string>Manually</string>
</property>
<property name="suffix">
<string> days</string>
</property>
<property name="prefix">
<string>After </string>
</property>
<property name="maximum">
<number>30</number>
</property>
<property name="empty_text" stdset="0">
<string/>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_4">
<property name="title">
<string>Appearance</string>
</property>
<layout class="QFormLayout" name="formLayout_4">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
</property>
<item row="0" column="0">
<widget class="QCheckBox" name="hide_listened">
<property name="text">
<string>Don't show listened episodes</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="show_episodes">
<property name="specialValueText">
<string>All</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Number of episodes to show</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>gpodder.net</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label_3">
<property name="text">
<string>Clementine can synchronize your subscription list with your other computers and podcast applications. &lt;a href=&quot;https://gpodder.net/register/&quot;&gt;Create an account&lt;/a&gt;.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="LoginStateWidget" name="login_state" native="true"/>
</item>
<item>
<widget class="QWidget" name="login_group" native="true">
<layout class="QFormLayout" name="formLayout_3">
<property name="margin">
<number>0</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Username</string>
</property>
</widget>
</item>
<item row="0" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="username"/>
</item>
<item>
<widget class="QPushButton" name="login">
<property name="text">
<string>Sign in</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Password</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="password">
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
<string>Device name</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLineEdit" name="device_name"/>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>0</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>LoginStateWidget</class>
<extends>QWidget</extends>
<header>widgets/loginstatewidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>check_interval</tabstop>
<tabstop>download_dir</tabstop>
<tabstop>download_dir_browse</tabstop>
<tabstop>auto_download</tabstop>
<tabstop>delete_after</tabstop>
<tabstop>username</tabstop>
<tabstop>password</tabstop>
<tabstop>device_name</tabstop>
<tabstop>login</tabstop>
</tabstops>
<connections/>
</ui>

View File

@@ -38,6 +38,7 @@
#include "core/settings.h"
#include "widgets/loginstatewidget.h"
#include "qobuz/qobuzservice.h"
#include "qobuz/qobuzcredentialfetcher.h"
#include "constants/qobuzsettings.h"
using namespace Qt::Literals::StringLiterals;
@@ -46,13 +47,15 @@ using namespace QobuzSettings;
QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *dialog, const SharedPtr<QobuzService> service, QWidget *parent)
: SettingsPage(dialog, parent),
ui_(new Ui::QobuzSettingsPage),
service_(service) {
service_(service),
credential_fetcher_(nullptr) {
ui_->setupUi(this);
setWindowIcon(IconLoader::Load(u"qobuz"_s, true, 0, 32));
QObject::connect(ui_->button_login, &QPushButton::clicked, this, &QobuzSettingsPage::LoginClicked);
QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &QobuzSettingsPage::LogoutClicked);
QObject::connect(ui_->button_fetch_credentials, &QPushButton::clicked, this, &QobuzSettingsPage::FetchCredentialsClicked);
QObject::connect(this, &QobuzSettingsPage::Login, &*service_, &StreamingService::LoginWithCredentials);
@@ -186,3 +189,40 @@ void QobuzSettingsPage::LoginFailure(const QString &failure_reason) {
QMessageBox::warning(this, tr("Authentication failed"), failure_reason);
}
void QobuzSettingsPage::FetchCredentialsClicked() {
ui_->button_fetch_credentials->setEnabled(false);
ui_->button_fetch_credentials->setText(tr("Fetching..."));
if (!credential_fetcher_) {
credential_fetcher_ = new QobuzCredentialFetcher(service_->network(), this);
QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetched, this, &QobuzSettingsPage::CredentialsFetched);
QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetchError, this, &QobuzSettingsPage::CredentialsFetchError);
}
credential_fetcher_->FetchCredentials();
}
void QobuzSettingsPage::CredentialsFetched(const QString &app_id, const QString &app_secret) {
ui_->app_id->setText(app_id);
ui_->app_secret->setText(app_secret);
ui_->checkbox_base64_secret->setChecked(false);
ui_->button_fetch_credentials->setEnabled(true);
ui_->button_fetch_credentials->setText(tr("Fetch Credentials"));
QMessageBox::information(this, tr("Credentials fetched"), tr("App ID and secret have been successfully fetched from the Qobuz web player."));
}
void QobuzSettingsPage::CredentialsFetchError(const QString &error) {
ui_->button_fetch_credentials->setEnabled(true);
ui_->button_fetch_credentials->setText(tr("Fetch Credentials"));
QMessageBox::warning(this, tr("Credential fetch failed"), error);
}

View File

@@ -30,6 +30,7 @@ class QShowEvent;
class QEvent;
class SettingsDialog;
class QobuzService;
class QobuzCredentialFetcher;
class Ui_QobuzSettingsPage;
class QobuzSettingsPage : public SettingsPage {
@@ -55,10 +56,14 @@ class QobuzSettingsPage : public SettingsPage {
void LogoutClicked();
void LoginSuccess();
void LoginFailure(const QString &failure_reason);
void FetchCredentialsClicked();
void CredentialsFetched(const QString &app_id, const QString &app_secret);
void CredentialsFetchError(const QString &error);
private:
Ui_QobuzSettingsPage *ui_;
const SharedPtr<QobuzService> service_;
QobuzCredentialFetcher *credential_fetcher_;
};
#endif // QOBUZSETTINGSPAGE_H

Some files were not shown because too many files have changed in this diff Show More