Compare commits

...

139 Commits

Author SHA1 Message Date
Jonas Kvinge
7eee74a2e9 Release 1.2.10 2025-04-18 20:04:22 +02:00
Jonas Kvinge
d9e38fb3be Remove Genius lyrics
No longer working properly because of website changes.
2025-04-18 15:56:30 +02:00
Jonas Kvinge
81cc90e54a Update Changelog 2025-04-18 02:38:37 +02:00
Jonas Kvinge
bd9771a88f TagReaderTagLib: Use TagLib::Tag::comment
Makes it use only commercial frames without description for comments, reading other commercial frames picks different iTunes tags we don't want.
2025-04-18 02:15:17 +02:00
Jonas Kvinge
f5cd81fe09 nsi: Re-enable Spotify 2025-04-16 23:25:03 +02:00
Gregor Santner
277e2cff59 Linux: Add Clementine search keyword to .desktop shortcut 2025-04-15 21:46:15 +02:00
Jonas Kvinge
6fa9514059 RichPresence: Only initialize discord when enabled 2025-04-13 21:45:55 +02:00
Jonas Kvinge
c5e38b71f7 discord_rpc: Use anonymous namespace 2025-04-13 21:34:40 +02:00
Jonas Kvinge
3746915ae7 RichPresence: Always include album 2025-04-13 19:19:53 +02:00
Jonas Kvinge
21bdf88d09 RichPresence: Remove unused variable 2025-04-13 12:16:57 +02:00
Jonas Kvinge
ff032c3cd7 RichPresence: Remove rate limit 2025-04-13 12:01:56 +02:00
Jonas Kvinge
c083110051 RichPresence: Move variable declaration
Fixes #1718
2025-04-13 11:52:16 +02:00
Jonas Kvinge
a7dbeb5d76 discord-rpc: Add copyright 2025-04-12 13:17:13 +02:00
Jonas Kvinge
634f6ea9f5 discord-rpc: Formatting 2025-04-12 13:17:00 +02:00
Jonas Kvinge
f9e4f9a09a discord-rpc: Formatting 2025-04-11 22:50:14 +02:00
Jonas Kvinge
aab9889174 Turn on git revision 2025-04-09 19:59:48 +02:00
Jonas Kvinge
3b560e4e4f Release 1.2.9 2025-04-08 23:52:00 +02:00
Jonas Kvinge
9e327c9556 Update Changelog 2025-04-08 23:52:00 +02:00
Jonas Kvinge
1ec640e088 LastFMImport: Fix progress 2025-04-08 23:05:44 +02:00
Jonas Kvinge
463aaf6942 Update Changelog 2025-04-08 21:19:29 +02:00
Jonas Kvinge
71287dd77e Add option to turn off playbin3 2025-04-08 21:19:29 +02:00
dependabot[bot]
b66c0f5573 Bump vmactions/openbsd-vm from 1.1.6 to 1.1.7
Bumps [vmactions/openbsd-vm](https://github.com/vmactions/openbsd-vm) from 1.1.6 to 1.1.7.
- [Release notes](https://github.com/vmactions/openbsd-vm/releases)
- [Commits](https://github.com/vmactions/openbsd-vm/compare/v1.1.6...v1.1.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-08 20:53:01 +02:00
Jonas Kvinge
a9f2c384fa RichPresence: Use class for activity 2025-04-08 20:52:34 +02:00
Jonas Kvinge
ae9584c213 Rename is_enabled to enabled 2025-04-08 20:33:54 +02:00
Jonas Kvinge
4db1c5ceb8 AlbumCoverFetcherSearch: Add debug for results 2025-04-08 20:33:27 +02:00
Jonas Kvinge
1738259467 SubsonicBaseRequest: Fix parsing
Fixes #1719
2025-04-08 20:18:54 +02:00
Jonas Kvinge
fcee02edc1 DeezerCoverProvider: Fix parsing
Fixes #1716
2025-04-08 20:04:02 +02:00
dependabot[bot]
4fff5820c5 Bump vmactions/freebsd-vm from 1.1.9 to 1.2.0
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.1.9 to 1.2.0.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.1.9...v1.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-07 19:32:24 +02:00
Jonas Kvinge
9aa6da2faf Mpris2: Ignore -Warray-bounds 2025-04-05 19:10:28 +02:00
Jonas Kvinge
279934411c Add Ubuntu Plucky 2025-04-05 18:31:08 +02:00
Jonas Kvinge
fd829551e8 Turn on git revision 2025-04-05 18:13:40 +02:00
Jonas Kvinge
be8f515388 Release 1.2.8 2025-04-05 12:38:22 +02:00
Jonas Kvinge
dd12462fbb Update Changelog 2025-04-05 12:38:22 +02:00
Strawberry Bot
5c3ded0099 New translations 2025-04-05 12:24:00 +02:00
Jonas Kvinge
2c9b14f5f2 GstEnginePipeline: Simplify version checks 2025-04-04 22:12:38 +02:00
Jonas Kvinge
8c195382c4 nsi: Disable spotify plugin 2025-04-03 22:51:12 +02:00
Jonas Kvinge
6e5198799c OAuthenticator: Rename function 2025-04-03 22:37:50 +02:00
Jonas Kvinge
389d04bbec OAuthenticator: Don't clear refresh token 2025-04-03 22:34:55 +02:00
Jonas Kvinge
6f0f88dd01 RichPresence: Simplify code 2025-04-03 22:21:51 +02:00
Jonas Kvinge
3dce84c73c MainWindow: Delay command line options until playlists are loaded
Fixes #1546
2025-03-30 16:45:34 +02:00
Jonas Kvinge
e798349aca Mpris2: Emit notifications when playlists are loaded
Fixes #1546
2025-03-30 16:43:29 +02:00
Jonas Kvinge
41ecf8e535 main: Add missing ifdef 2025-03-30 01:56:58 +01:00
Jonas Kvinge
70c96ded28 rpm: Add BuildRequires for cmake(RapidJSON) 2025-03-30 01:22:57 +01:00
Jonas Kvinge
d9cc6bf238 debian/control: Add rapidjson-dev 2025-03-30 01:22:57 +01:00
Jonas Kvinge
0a70ac1c95 Song: Update supported tags 2025-03-30 01:22:20 +01:00
Jonas Kvinge
e3a3f44513 TagReaderTagLib: Add full support for AIFF 2025-03-30 01:22:07 +01:00
Jonas Kvinge
c2e42ebf3a Song: Include fingerprints in IsSimilar
Fixes #1710
2025-03-30 00:34:43 +01:00
Jonas Kvinge
d71b344e77 MainWindow: Add missing ifdef 2025-03-30 00:07:22 +01:00
Jonas Kvinge
d5f7a4b883 RichPresence: Formatting and add settings reload 2025-03-30 00:06:05 +01:00
Jonas Kvinge
34fb289e33 serialization: Remove unneeded pragams 2025-03-29 23:01:41 +01:00
Jonas Kvinge
6d4d8251ad Update Changelog 2025-03-29 22:53:37 +01:00
Jonas Kvinge
fc69ef27e8 README: Add Discord rich presence 2025-03-29 22:46:50 +01:00
Jonas Kvinge
bbd8a24b75 discord: Add namespace and cleanup CMakeLists 2025-03-29 22:41:58 +01:00
fruityloops1
9fa9012c70 Discord RPC implementation 2025-03-29 22:41:58 +01:00
fruityloops1
2a4fc185ac Add discord-rpc library 2025-03-29 22:41:58 +01:00
Jonas Kvinge
fa0703246b Equalizer: Ignore -Warray-bounds 2025-03-28 23:09:49 +01:00
corubba
954c21e21e AlsaDeviceFinder: Use card id instead of card index
Like the card index, the card id is guaranteed to be unique. While card
index can easily change between reboots, the card id is based on the
actual audio hardware and does not change between reboots; or even
hardware changes. This makes using the card id preferable, because it
will "just work" 99% of the time, and removes the need to force cards to
have a specific index.

There is a corner case where card ids may change between reboots: If you
have two (or more) of the same audio hardware in the system. But that
should be rare enough, and requires explicit system configuration
anyway, so using the "custom" option should work here.

If there is an previously-saved index-based ALSA device in the config,
it will continue to work as-is and does not need to be migrated. There
is only a small UI side-effect: Because the index-based device will no
longer match any found id-based device, the settings window will show it
as "custom". Simply selecting the ALSA device from the drop-down again
will change it to the id-based device.
2025-03-28 19:54:32 +01:00
Jonas Kvinge
8954dfb0aa CMake: Add -W4 with MSVC 2025-03-25 18:05:41 +01:00
Jonas Kvinge
5e031be42c Fix cast warnings with MSVC 2025-03-25 18:05:41 +01:00
dependabot[bot]
d5281abb22 Bump apple-actions/import-codesign-certs from 3 to 5
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 3 to 5.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v3...v5)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-25 17:22:05 +01:00
Jonas Kvinge
f5368d8108 Revert "Bump apple-actions/import-codesign-certs from 3 to 4"
This reverts commit e2dc22c2c8.
2025-03-21 20:35:36 +01:00
dependabot[bot]
e2dc22c2c8 Bump apple-actions/import-codesign-certs from 3 to 4
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 3 to 4.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v3...v4)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-21 18:21:12 +01:00
Strawberry Bot
817ca828e6 New translations 2025-03-18 22:44:29 +01:00
dependabot[bot]
61dc2cc640 Bump vmactions/freebsd-vm from 1.1.8 to 1.1.9
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.1.8 to 1.1.9.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.1.8...v1.1.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-18 17:58:35 +01:00
Jonas Kvinge
cd4adf6f89 ebur128analysis: Handle extra enums with GStreamer 1.25 and higher 2025-03-17 22:57:36 +01:00
Jonas Kvinge
91ebcc948e debian/rules: Build with -DBUILD_WERROR=ON 2025-03-17 22:28:24 +01:00
Jonas Kvinge
e7e6d59172 Remove KDSingleApplication from 3rdparty 2025-03-17 21:38:11 +01:00
Jonas Kvinge
95b12a01a4 OAuthenticator: Fix logging 2025-03-17 19:42:59 +01:00
Jonas Kvinge
5947aeae24 nsi: Bump icu 2025-03-15 23:29:12 +01:00
Jonas Kvinge
0298fa0b73 TidalBaseRequest: Don't clear session 2025-03-14 20:18:53 +01:00
Jonas Kvinge
05d72c8bd6 nsi: Add asio for mingw 2025-03-13 00:10:37 +01:00
Roman Lebedev
70b7c4560d gst_channel_to_ebur_channel(): handle new top-surround channels
These seem to have appeared in gstreamer 1.26,
which is the version we need to use to guard the handling.

These are effectively geometrically located on the same azimuth,
but on the layer above than the non-top (i.e. middle layer)
surround channels. But they are still surround channels,
which ebur128 does not bias loudness-wise.

At least this is my understanding.
2025-03-12 22:20:56 +01:00
Roman Lebedev
2687dc31cc Support arbitrarily large EBU R 128 loudness normalization
While i have fixed gstreamer's `volume` in
https://gitlab.freedesktop.org/gstreamer/gstreamer/-/merge_requests/5063
i did not see anything that followed after it was merged, namely, in
https://gitlab.freedesktop.org/gstreamer/gstreamer/-/merge_requests/6222,
the feature was moved, `"volume"` was reverted to only handle `x10` gain,
and one needs to use `"volume-full-range"` instead to do arbitrary gain.
So let's do that.

This, of course, requires run-time detection of the version
of gstreamer base plugins that we are running with,
specifically, we need version `1.24`.
2025-03-12 22:20:56 +01:00
Jonas Kvinge
cabf1cb78d CI: Bump macOS runner 2025-03-11 22:54:34 +01:00
Jonas Kvinge
d9f68ab944 Application: Add QTimer include
Fixes #1695
2025-03-09 11:29:51 +01:00
Jonas Kvinge
8630c5329d LyricsFetcherSearch: Fix authentication check 2025-03-09 02:11:12 +01:00
Jonas Kvinge
66e175f6d1 PlaylistItem: Add dtor 2025-03-08 23:46:12 +01:00
Jonas Kvinge
1173d5f865 Lyrics: Refactor 2025-03-08 23:26:44 +01:00
Jonas Kvinge
b02b114caf Scrobbler: Refactor 2025-03-08 23:19:42 +01:00
Jonas Kvinge
cd516c37b9 Refactor Tidal, Spotify, Qobuz, Subsonic and cover providers
Use common HTTP, Json and OAuthenticator class
2025-03-08 23:11:07 +01:00
Jonas Kvinge
7de8a44709 Add OAuthenticator 2025-03-08 22:46:59 +01:00
Jonas Kvinge
bb345b14de Add base classes for HTTP and Json 2025-03-08 22:46:46 +01:00
Jonas Kvinge
baa82966d8 Move SearchType to StreamingService 2025-03-08 22:41:43 +01:00
Jonas Kvinge
f85d60f5cd Formatting 2025-03-08 22:31:00 +01:00
Jonas Kvinge
6f731fcf4a MainWindow: Add const 2025-03-08 22:30:27 +01:00
Jonas Kvinge
bdbe66b116 Support more collections 2025-03-08 22:24:28 +01:00
Jonas Kvinge
5ae0320911 CollectionBackend: Add delete songs by URLs function 2025-03-08 21:46:27 +01:00
Jonas Kvinge
31671af5f5 MultiLoadingIndicator: Only emit task count change when needed 2025-03-08 21:45:34 +01:00
Jonas Kvinge
27184eb001 Utilities: Add StringListToHTML 2025-03-08 21:45:00 +01:00
Jonas Kvinge
2e30f5c585 StreamTagReader: Rename variable 2025-03-08 21:44:33 +01:00
Jonas Kvinge
109d3f9ec3 PlaylistBackend: Move query to static function 2025-03-08 21:36:42 +01:00
Jonas Kvinge
e9d413c7dc QTcpServer: Add success and port 2025-03-08 21:26:47 +01:00
Jonas Kvinge
ee60191b6c MusicBrainzClient: Formatting 2025-03-08 21:24:59 +01:00
Jonas Kvinge
a6d8627129 AcoustidClient: Formatting 2025-03-08 21:24:29 +01:00
Jonas Kvinge
d317c9158b UrlHandler: Formatting 2025-03-08 21:23:02 +01:00
Jonas Kvinge
3716e8c3ef CollectionModel: Rename variable 2025-03-08 21:22:40 +01:00
Jonas Kvinge
ce0e1e900e Update README.md 2025-03-04 23:31:23 +01:00
Jonas Kvinge
f1aa92ec9f Update README.md 2025-03-04 21:25:35 +01:00
Jonas Kvinge
25bdfcdb76 nsi: Update sqlite3 dll name 2025-02-20 22:37:43 +01:00
Jonas Kvinge
6a3de3937a AppearanceSettingsPage: Add tooltip about restart
You need to restart Strawberry for this setting to take affect.
2025-02-20 16:11:24 +01:00
Jonas Kvinge
5f775e87ae BackendSettingsPage: Add tooltip for HTTP/2
You need to restart Strawberry for this setting to take affect
2025-02-20 16:10:21 +01:00
Jonas Kvinge
1fd83c55ee Equalizer: Add tooltip that playback must be restarted 2025-02-20 16:09:10 +01:00
Jonas Kvinge
e6e9edef7d tests: Remove unused TestObjectDecorators 2025-02-18 23:00:01 +01:00
Jonas Kvinge
1bae665f76 CI: Fix OpenMandriva build 2025-02-18 22:02:22 +01:00
Jonas Kvinge
5bfc8b74f5 CI: Fix Mageia build 2025-02-18 22:02:12 +01:00
Jonas Kvinge
e588896729 FilterParser: Update tooltip
Fixes #1680
2025-02-18 17:08:17 +01:00
Jonas Kvinge
0cd0f7f2e7 FilesystemMusicStorage: Use QFile::supportsMoveToTrash 2025-02-18 16:55:02 +01:00
Jonas Kvinge
6db540a3a7 nsi: Bump libFLAC version 2025-02-12 01:25:12 +01:00
Jonas Kvinge
82679e0cea Fix issue template 2025-02-12 01:07:04 +01:00
Jonas Kvinge
d571bc3305 TagReaderTagLib: Fix build without stream tagreader
Fixes #1672
2025-02-10 23:29:40 +01:00
Jonas Kvinge
2b52553864 Add stream tagreader 2025-02-08 02:53:10 +01:00
Jonas Kvinge
215627b0e4 TagReaderGME: Fix use of Qt::CaseInsensitive and length check 2025-02-08 01:17:44 +01:00
Jonas Kvinge
b17cae6ec7 rpm: Exclude %debug_package on tumbleweed 2025-02-07 22:23:04 +01:00
Jonas Kvinge
30ac9697ea BackendSettingsPage: Increase device lineedit height
Bottom of the text was cut off with the breeze style
2025-02-07 21:27:17 +01:00
Jonas Kvinge
61e3ea249d Turn off "Grey out unavailable songs in playlists on startup" by default 2025-02-02 23:48:05 +01:00
Jonas Kvinge
d1986eeae2 Tidal: Save token type 2025-02-01 22:25:53 +01:00
Jonas Kvinge
ba354207d2 Tidal: Remove deprecated username/password login 2025-02-01 22:10:53 +01:00
Jonas Kvinge
eac5674891 TidalService: Clear refresh token on sign out 2025-02-01 00:50:17 +01:00
Jonas Kvinge
8349a8b0ee Port back to "output" and "device" settings in lowercase
Was accidentally changed to capitalized.
2025-02-01 00:48:57 +01:00
Jonas Kvinge
b9b4e9f831 TidalSettingsPage: Add HI_RES_LOSSLESS 2025-01-31 23:17:19 +01:00
Jonas Kvinge
98ff2525f0 Turn on git revision 2025-01-31 18:26:03 +01:00
Jonas Kvinge
3fd29c6dcc Release 1.2.7 2025-01-31 16:35:19 +01:00
Jonas Kvinge
4429e9973f StandardItemIconLoader : Rename LoadIcon
Avoids conflict on Windows where windows headers define LoadIcon to LoadIconW with unicode.
2025-01-31 16:20:03 +01:00
Jonas Kvinge
1572d241d5 Replace Windows conflicting "LoadIcon" with "SetIcon"
Windows headers defines LoadIcon to LoadIconW when UNICODE is defined.
2025-01-31 16:10:23 +01:00
Jonas Kvinge
eae7e2a9e6 Update Changelog 2025-01-31 13:09:38 +01:00
Jonas Kvinge
251e5b379b Disable OSD Pretty on Wayland 2025-01-29 22:12:29 +01:00
Jonas Kvinge
0db082fca0 Replace Q_OS_WIN with Q_OS_WIN32 2025-01-28 20:30:43 +01:00
Jonas Kvinge
2799e55076 OSDPretty: Set window title 2025-01-28 20:24:27 +01:00
Jonas Kvinge
440ee43a91 Update Changelog 2025-01-27 14:56:06 +01:00
Jonas Kvinge
98c72ec1f8 import-from-clementine: Minor tidy 2025-01-27 14:55:51 +01:00
Jonas Kvinge
759488ae1a CMake: Reword QPlatformNativeInterface option 2025-01-27 14:55:30 +01:00
Jonas Kvinge
055cb413c9 import-from-clementine: Handle NULL filename and remove FTS
Fixes #1660
2025-01-27 14:41:14 +01:00
Jonas Kvinge
f760b87b58 CI: Add missing dependencies for PPA 2025-01-25 19:37:58 +01:00
Jonas Kvinge
39f228f862 CMake: Add QPA Platform interface as optional component 2025-01-25 18:31:22 +01:00
Jonas Kvinge
a683a279f5 CMake: Define NOMINMAX on Windows 2025-01-24 12:46:49 +01:00
Jonas Kvinge
e800926f57 Ignore -Wcpp for gtest.h/gmock.h 2025-01-24 12:46:48 +01:00
Strawberry Bot
dd9f80d539 New translations 2025-01-24 10:58:02 +01:00
Jonas Kvinge
8484cac4ed Add strawberry_fr_BE.ts 2025-01-24 10:42:40 +01:00
Strawberry Bot
e24097582f New translations 2025-01-24 10:40:12 +01:00
367 changed files with 32712 additions and 7966 deletions

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -68,6 +68,7 @@ jobs:
hicolor-icon-theme
qt6-core-devel
qt6-gui-devel
qt6-gui-private-devel
qt6-widgets-devel
qt6-concurrent-devel
qt6-network-devel
@@ -79,9 +80,22 @@ jobs:
qt6-linguist-devel
gtest
gmock
sparsehash-devel
rapidjson-devel
- name: Install kdsingleapplication-qt6-devel
if: matrix.opensuse_version == 'tumbleweed'
run: zypper -n --gpg-auto-import-keys in kdsingleapplication-qt6-devel
- name: Build and install KDSingleApplication
if: matrix.opensuse_version == 'leap:15.6'
env:
CC: gcc-14
CXX: g++-14
run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication
cmake -S . -B build -DCMAKE_BUILD_TYPE="Release" -DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON -DKDSingleApplication_STATIC=ON
cmake --build build --config Release --parallel 4
cmake --install build
- name: Checkout
uses: actions/checkout@v4
with:
@@ -192,6 +206,8 @@ jobs:
kdsingleapplication-qt6-devel
gtest-devel
gmock-devel
sparsehash-devel
rapidjson-devel
- name: Checkout
uses: actions/checkout@v4
with:
@@ -228,7 +244,7 @@ jobs:
build-openmandriva:
name: Build OpenMandriva
if: github.repository != 'strawberrymusicplayer/strawberry-private' && github.ref != 'refs/heads/l10n_master' && false
if: github.repository != 'strawberrymusicplayer/strawberry-private' && github.ref != 'refs/heads/l10n_master'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -243,31 +259,30 @@ jobs:
run: >
dnf install -y
which
glibc
gcc-c++
git
gnutar
make
cmake
glib
lsb-release
rpmdevtools
rpm-build
glibc-devel
boost-devel
sqlite-devel
libasound-devel
pulseaudio-devel
libGL-devel
libgst-plugins-base1.0-devel
taglib-devel
chromaprint-devel
libebur128-devel
fftw-devel
icu-devel
libcdio-devel
libgpod-devel
libmtp-devel
lib64glib2.0-devel
lib64asound-devel
lib64pulseaudio-devel
lib64sqlite3-devel
lib64gstreamer-devel
lib64gst-plugins-base1.0-devel
lib64taglib-devel
lib64chromaprint-devel
lib64ebur128-devel
lib64fftw-devel
lib64icu-devel
lib64cdio-devel
lib64gpod-devel
lib64mtp-devel
lib64Qt6Core-devel
lib64Qt6Concurrent-devel
lib64Qt6Network-devel
@@ -276,6 +291,11 @@ jobs:
lib64Qt6Gui-devel
lib64Qt6Widgets-devel
lib64Qt6Test-devel
lib64kdsingleapplication-devel
lib64xkbcommon-devel
lib64gtest-devel
lib64gmock-devel
sparsehash-devel
qt6-cmake
qt6-qtbase-tools
qt6-qttools-linguist
@@ -283,6 +303,7 @@ jobs:
appstream
appstream-util
hicolor-icon-theme
rapidjson
- name: Remove files
run: rm -rf /usr/lib64/qt6/lib/cmake/Qt6Sql/{Qt6QMYSQL*,Qt6QODBCD*,Qt6QPSQL*,Qt6QIBase*}
- name: Checkout
@@ -331,12 +352,6 @@ jobs:
container:
image: mageia:${{matrix.mageia_version}}
steps:
- name: Set media
run: |
urpmi.removemedia "Core Release"
urpmi.removemedia "Core Updates"
urpmi.addmedia "Core Release" "https://mirrors.kernel.org/mageia/distrib/${{matrix.mageia_version}}/x86_64/media/core/release/"
urpmi.addmedia "Core Updates" "https://mirrors.kernel.org/mageia/distrib/${{matrix.mageia_version}}/x86_64/media/core/updates/"
- name: Update repositories
run: urpmi.update -a
- name: Upgrade packages
@@ -378,10 +393,21 @@ jobs:
lib64qt6dbus-devel
lib64qt6help-devel
lib64qt6test-devel
lib64sparsehash-devel
lib64kdsingleapplication-devel
desktop-file-utils
appstream-util
hicolor-icon-theme
gtest
rapidjson
- name: Build and install KDSingleApplication
if: matrix.mageia_version == '9'
run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication
cmake -S . -B build -DCMAKE_BUILD_TYPE="Release" -DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON -DKDSingleApplication_STATIC=ON
cmake --build build --config Release --parallel 4
cmake --install build
- name: Checkout
uses: actions/checkout@v4
with:
@@ -464,11 +490,22 @@ jobs:
libcdio-dev
libmtp-dev
libgpod-dev
libxkbcommon-dev
libsparsehash-dev
qt6-base-dev
qt6-base-private-dev
qt6-base-dev-tools
qt6-tools-dev
qt6-tools-dev-tools
qt6-l10n-tools
rapidjson-dev
- name: Build and install KDSingleApplication
run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication
cmake -S . -B build -DCMAKE_BUILD_TYPE="Release" -DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON -DKDSingleApplication_STATIC=ON
cmake --build build --config Release --parallel 4
cmake --install build
- name: Checkout
uses: actions/checkout@v4
with:
@@ -501,7 +538,7 @@ jobs:
strategy:
fail-fast: false
matrix:
ubuntu_version: [ 'noble', 'oracular' ]
ubuntu_version: [ 'noble', 'oracular', 'plucky' ]
container:
image: ubuntu:${{matrix.ubuntu_version}}
steps:
@@ -545,11 +582,22 @@ jobs:
libcdio-dev
libmtp-dev
libgpod-dev
libxkbcommon-dev
libsparsehash-dev
qt6-base-dev
qt6-base-private-dev
qt6-base-dev-tools
qt6-tools-dev
qt6-tools-dev-tools
qt6-l10n-tools
rapidjson-dev
- name: Build and install KDSingleApplication
run: |
git clone --depth 1 --recurse-submodules https://github.com/KDAB/KDSingleApplication
cd KDSingleApplication
cmake -S . -B build -DCMAKE_BUILD_TYPE="Release" -DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON -DKDSingleApplication_STATIC=ON
cmake --build build --config Release --parallel 4
cmake --install build
- name: Checkout
uses: actions/checkout@v4
with:
@@ -583,7 +631,7 @@ jobs:
strategy:
fail-fast: false
matrix:
ubuntu_version: [ 'noble', 'oracular' ]
ubuntu_version: [ 'noble', 'oracular', 'plucky' ]
container:
image: ubuntu:${{matrix.ubuntu_version}}
steps:
@@ -625,13 +673,18 @@ jobs:
libcdio-dev
libmtp-dev
libgpod-dev
libxkbcommon-dev
libsparsehash-dev
qt6-base-dev
qt6-base-private-dev
qt6-base-dev-tools
qt6-tools-dev
qt6-tools-dev-tools
qt6-l10n-tools
gstreamer1.0-alsa
gstreamer1.0-pulseaudio
libkdsingleapplication-qt6-dev
rapidjson-dev
- name: Install keyboxd
if: matrix.ubuntu_version == 'noble'
env:
@@ -644,6 +697,10 @@ jobs:
submodules: recursive
- name: Add safe git directory
run: git config --global --add safe.directory ${GITHUB_WORKSPACE}
- name: Modify required KDSingleApplication version
run: |
sed -i 's/^find_package(KDSingleApplication-qt\${QT_VERSION_MAJOR} 1.1.0 REQUIRED)$/find_package(KDSingleApplication-qt\${QT_VERSION_MAJOR} 1.0.0 REQUIRED)/g' CMakeLists.txt
sed -i 's/, KDSingleApplication::Option::IncludeUsernameInSocketName);$/);/g' src/main.cpp
- name: Create Build Environment
run: cmake -E make_directory build
- name: Configure CMake
@@ -676,11 +733,11 @@ jobs:
submodules: recursive
- name: Build FreeBSD
id: build-freebsd
uses: vmactions/freebsd-vm@v1.1.8
uses: vmactions/freebsd-vm@v1.2.0
with:
usesh: true
mem: 4096
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
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
git config --global --add safe.directory ${GITHUB_WORKSPACE}
@@ -701,17 +758,17 @@ jobs:
submodules: recursive
- name: Build OpenBSD
id: build-openbsd
uses: vmactions/openbsd-vm@v1.1.6
uses: vmactions/openbsd-vm@v1.1.7
with:
usesh: true
mem: 4096
prepare: pkg_add git cmake pkgconf boost glib2 qt6-qtbase qt6-qttools sqlite gstreamer1 gstreamer1-plugins-base chromaprint libebur128 taglib libcdio libmtp gdk-pixbuf libgpod fftw3 icu4c kdsingleapplication pulseaudio
prepare: pkg_add git cmake pkgconf boost glib2 qt6-qtbase qt6-qttools sqlite gstreamer1 gstreamer1-plugins-base chromaprint libebur128 taglib libcdio libmtp gdk-pixbuf libgpod fftw3 icu4c kdsingleapplication pulseaudio sparsehash rapidjson
run: |
set -e
export LDFLAGS="-L/usr/local/lib"
git config --global --add safe.directory ${GITHUB_WORKSPACE}
cmake -E make_directory build
cmake -S . -B build -DCMAKE_BUILD_TYPE="Debug" -DENABLE_ALSA=OFF
cmake -S . -B build -DCMAKE_BUILD_TYPE="Debug" -DENABLE_ALSA=OFF -DENABLE_DISCORD_RPC=OFF
cmake --build build --config Debug --parallel 4
@@ -722,7 +779,7 @@ jobs:
strategy:
fail-fast: false
matrix:
runner: [ 'macos-13', 'macos-14' ]
runner: [ 'macos-13', 'macos-15' ]
buildtype: [ 'release' ]
runs-on: ${{ matrix.runner }}
@@ -731,7 +788,7 @@ jobs:
- name: Set MACOSX_DEPLOYMENT_TARGET
run: |
for i in 12 13 14 15; do
for i in 13 14 15; do
if [ -d "/Library/Developer/CommandLineTools/SDKs/MacOSX${i}.sdk" ]; then
echo "Using macOS SDK ${i}"
echo "MACOSX_DEPLOYMENT_TARGET=${i}.0" >> $GITHUB_ENV
@@ -768,7 +825,7 @@ jobs:
- name: Import certificate file
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false
uses: apple-actions/import-codesign-certs@v3
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }}
@@ -830,7 +887,7 @@ jobs:
run: codesign -s 383J84DVB6 -f strawberry.app/Contents/Frameworks/{libpcre2-8.0.dylib,libpcre2-16.0.dylib,libpng16.16.dylib,libfreetype.6.dylib,libzstd.1.dylib} strawberry.app/Contents/Frameworks/png.framework/png strawberry.app
- name: Manually Codesign
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-14'
if: github.repository == 'strawberrymusicplayer/strawberry' && github.event.pull_request.head.repo.fork == false && matrix.runner == 'macos-15'
working-directory: build
run: codesign -s 383J84DVB6 -f strawberry.app/Contents/Frameworks/png.framework/png strawberry.app
@@ -889,7 +946,7 @@ jobs:
- name: Set MACOSX_DEPLOYMENT_TARGET
run: |
for i in 12 13 14 15; do
for i in 13 14 15; do
if [ -d "/Library/Developer/CommandLineTools/SDKs/MacOSX${i}.sdk" ]; then
echo "Using macOS SDK ${i}"
echo "MACOSX_DEPLOYMENT_TARGET=${i}.0" >> $GITHUB_ENV

4
.gitmodules vendored
View File

@@ -1,4 +0,0 @@
[submodule "3rdparty/kdsingleapplication/KDSingleApplication"]
path = 3rdparty/kdsingleapplication/KDSingleApplication
url = https://github.com/KDAB/KDSingleApplication.git
branch = master

11
3rdparty/README.md vendored
View File

@@ -1,11 +0,0 @@
3rdparty libraries located in this directory
============================================
KDSingleApplication
-------------------
A small library used by Strawberry to prevent it from starting twice per user session.
If the user tries to start strawberry twice, the main window will maximize instead of starting another instance.
It is also used to pass command-line options through to the first instance.
This 3rdparty copy is used only if KDSingleApplication 1.1 or higher is not found on the system.
URL: https://github.com/KDAB/KDSingleApplication/

41
3rdparty/discord-rpc/CMakeLists.txt vendored Normal file
View File

@@ -0,0 +1,41 @@
set(DISCORD_RPC_SOURCES
discord_rpc.h
discord_register.h
discord_rpc.cpp
discord_rpc_connection.h
discord_rpc_connection.cpp
discord_serialization.h
discord_serialization.cpp
discord_connection.h
discord_backoff.h
discord_msg_queue.h
)
if(UNIX)
list(APPEND DISCORD_RPC_SOURCES discord_connection_unix.cpp)
if(APPLE)
list(APPEND DISCORD_RPC_SOURCES discord_register_osx.m)
add_definitions(-DDISCORD_OSX)
else()
list(APPEND DISCORD_RPC_SOURCES discord_register_linux.cpp)
add_definitions(-DDISCORD_LINUX)
endif()
endif()
if(WIN32)
list(APPEND DISCORD_RPC_SOURCES discord_connection_win.cpp discord_register_win.cpp)
add_definitions(-DDISCORD_WINDOWS)
endif()
add_library(discord-rpc STATIC ${DISCORD_RPC_SOURCES})
if(APPLE)
target_link_libraries(discord-rpc PRIVATE "-framework AppKit")
endif()
if(WIN32)
target_link_libraries(discord-rpc PRIVATE psapi advapi32)
endif()
target_include_directories(discord-rpc SYSTEM PRIVATE ${RapidJSON_INCLUDE_DIRS})
target_include_directories(discord-rpc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

19
3rdparty/discord-rpc/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright 2017 Discord, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

162
3rdparty/discord-rpc/README.md vendored Normal file
View File

@@ -0,0 +1,162 @@
# Discord RPC
## Fork Notice
This library was slightly modified for Strawberry Music Player with some extra features from the new API and shared library support/more unnecessary components removed. The original repository is [here](https://github.com/discord/discord-rpc)
## Deprecation Notice
This library has been deprecated in favor of Discord's GameSDK. [Learn more here](https://discordapp.com/developers/docs/game-sdk/sdk-starter-guide)
---
This is a library for interfacing your game with a locally running Discord desktop client. It's known to work on Windows, macOS, and Linux. You can use the lib directly if you like, or use it as a guide to writing your own if it doesn't suit your game as is. PRs/feedback welcome if you have an improvement everyone might want, or can describe how this doesn't meet your needs.
Included here are some quick demos that implement the very minimal subset to show current status, and
have callbacks for where a more complete game would do more things (joining, spectating, etc).
## Documentation
The most up to date documentation for Rich Presence can always be found on our [developer site](https://discordapp.com/developers/docs/rich-presence/how-to)! If you're interested in rolling your own native implementation of Rich Presence via IPC sockets instead of using our SDK—hey, you've got free time, right?—check out the ["Hard Mode" documentation](https://github.com/discordapp/discord-rpc/blob/master/documentation/hard-mode.md).
## Basic Usage
Zeroith, you should be set up to build things because you are a game developer, right?
First, head on over to the [Discord developers site](https://discordapp.com/developers/applications/me) and make yourself an app. Keep track of `Client ID` -- you'll need it here to pass to the init function.
### Unreal Engine 4 Setup
To use the Rich Presense plugin with Unreal Engine Projects:
1. Download the latest [release](https://github.com/discordapp/discord-rpc/releases) for each operating system you are targeting and the zipped source code
2. In the source code zip, copy the UE plugin—`examples/unrealstatus/Plugins/discordrpc`—to your project's plugin directory
3. At `[YOUR_UE_PROJECT]/Plugins/discordrpc/source/ThirdParty/DiscordRpcLibrary/`, create an `Include` folder and copy `discord_rpc.h` and `discord_register.h` to it from the zip
4. Follow the steps below for each OS
5. Build your UE4 project
6. Launch the editor, and enable the Discord plugin.
#### Windows
- At `[YOUR_UE_PROJECT]/Plugins/discordrpc/source/ThirdParty/DiscordRpcLibrary/`, create a `Win64` folder
- Copy `lib/discord-rpc.lib` and `bin/discord-rpc.dll` from `[RELEASE_ZIP]/win64-dynamic` to the `Win64` folder
#### Mac
- At `[YOUR_UE_PROJECT]/Plugins/discordrpc/source/ThirdParty/DiscordRpcLibrary/`, create a `Mac` folder
- Copy `libdiscord-rpc.dylib` from `[RELEASE_ZIP]/osx-dynamic/lib` to the `Mac` folder
#### Linux
- At `[YOUR_UE_PROJECT]/Plugins/discordrpc/source/ThirdParty/DiscordRpcLibrary/`, create a `Linux` folder
- Inside, create another folder `x86_64-unknown-linux-gnu`
- Copy `libdiscord-rpc.so` from `[RELEASE_ZIP]/linux-dynamic/lib` to `Linux/x86_64-unknown-linux-gnu`
### Unity Setup
If you're a Unity developer looking to integrate Rich Presence into your game, follow this simple guide to get started towards success:
1. Download the DLLs for any platform that you need from [our releases](https://github.com/discordapp/discord-rpc/releases)
2. In your Unity project, create a `Plugins` folder inside your `Assets` folder if you don't already have one
3. Copy the file `DiscordRpc.cs` from [here](https://github.com/discordapp/discord-rpc/blob/master/examples/button-clicker/Assets/DiscordRpc.cs) into your `Assets` folder. This is basically your header file for the SDK
We've got our `Plugins` folder ready, so let's get platform-specific!
#### Windows
4. Create `x86` and `x86_64` folders inside `Assets/Plugins/`
5. Copy `discord-rpc-win/win64-dynamic/bin/discord-rpc.dll` to `Assets/Plugins/x86_64/`
6. Copy `discord-rpc-win/win32-dynamic/bin/discord-rpc.dll` to `Assets/Plugins/x86/`
7. Click on both DLLs and make sure they are targetting the correct architectures in the Unity editor properties pane
8. Done!
#### MacOS
4. Copy `discord-rpc-osx/osx-dynamic/lib/libdiscord-rpc.dylib` to `Assets/Plugins/`
5. Rename `libdiscord-rpc.dylib` to `discord-rpc.bundle`
6. Done!
#### Linux
4. Copy `discord-rpc-linux/linux-dynamic-lib/libdiscord-rpc.so` to `Assets/Plugins/`
5. Done!
You're ready to roll! For code examples on how to interact with the SDK using the `DiscordRpc.cs` header file, check out [our example](https://github.com/discordapp/discord-rpc/blob/master/examples/button-clicker/Assets/DiscordController.cs)
### From package
Download a release package for your platform(s) -- they have subdirs with various prebuilt options, select the one you need add `/include` to your compile includes, `/lib` to your linker paths, and link with `discord-rpc`. For the dynamically linked builds, you'll need to ship the associated file along with your game.
### From repo
First-eth, you'll want `CMake`. There's a few different ways to install it on your system, and you should refer to [their website](https://cmake.org/install/). Many package managers provide ways of installing CMake as well.
To make sure it's installed correctly, type `cmake --version` into your flavor of terminal/cmd. If you get a response with a version number, you're good to go!
There's a [CMake](https://cmake.org/download/) file that should be able to generate the lib for you; Sometimes I use it like this:
```sh
cd <path to discord-rpc>
mkdir build
cd build
cmake .. -DCMAKE_INSTALL_PREFIX=<path to install discord-rpc to>
cmake --build . --config Release --target install
```
There is a wrapper build script `build.py` that runs `cmake` with a few different options.
Usually, I run `build.py` to get things started, then use the generated project files as I work on things. It does depend on `click` library, so do a quick `pip install click` to make sure you have it if you want to run `build.py`.
There are some CMake options you might care about:
| flag | default | does |
| ---------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ENABLE_IO_THREAD` | `ON` | When enabled, we start up a thread to do io processing, if disabled you should call `Discord_UpdateConnection` yourself. |
| `USE_STATIC_CRT` | `OFF` | (Windows) Enable to statically link the CRT, avoiding requiring users install the redistributable package. (The prebuilt binaries enable this option) |
| [`BUILD_SHARED_LIBS`](https://cmake.org/cmake/help/v3.7/variable/BUILD_SHARED_LIBS.html) | `OFF` | Build library as a DLL |
| `WARNINGS_AS_ERRORS` | `OFF` | When enabled, compiles with `-Werror` (on \*nix platforms). |
## Continuous Builds
Why do we have three of these? Three times the fun!
| CI | badge |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| TravisCI | [![Build status](https://travis-ci.org/discordapp/discord-rpc.svg?branch=master)](https://travis-ci.org/discordapp/discord-rpc) |
| AppVeyor | [![Build status](https://ci.appveyor.com/api/projects/status/qvkoc0w1c4f4b8tj?svg=true)](https://ci.appveyor.com/project/crmarsh/discord-rpc) |
| Buildkite (internal) | [![Build status](https://badge.buildkite.com/e103d79d247f6776605a15246352a04b8fd83d69211b836111.svg)](https://buildkite.com/discord/discord-rpc) |
## Sample: send-presence
This is a text adventure "game" that inits/deinits the connection to Discord, and sends a presence update on each command.
## Sample: button-clicker
This is a sample [Unity](https://unity3d.com/) project that wraps a DLL version of the library, and sends presence updates when you click on a button. Run `python build.py unity` in the root directory to build the correct library files and place them in their respective folders.
## Sample: unrealstatus
This is a sample [Unreal](https://www.unrealengine.com) project that wraps the DLL version of the library with an Unreal plugin, exposes a blueprint class for interacting with it, and uses that to make a very simple UI. Run `python build.py unreal` in the root directory to build the correct library files and place them in their respective folders.
## Wrappers and Implementations
Below is a table of unofficial, community-developed wrappers for and implementations of Rich Presence in various languages. If you would like to have yours added, please make a pull request adding your repository to the table. The repository should include:
- The code
- A brief ReadMe of how to use it
- A working example
###### Rich Presence Wrappers and Implementations
| Name | Language |
| ------------------------------------------------------------------------- | --------------------------------- |
| [Discord RPC C#](https://github.com/Lachee/discord-rpc-csharp) | C# |
| [Discord RPC D](https://github.com/voidblaster/discord-rpc-d) | [D](https://dlang.org/) |
| [discord-rpc.jar](https://github.com/Vatuu/discord-rpc 'Discord-RPC.jar') | Java |
| [java-discord-rpc](https://github.com/MinnDevelopment/java-discord-rpc) | Java |
| [Discord-IPC](https://github.com/jagrosh/DiscordIPC) | Java |
| [Discord Rich Presence](https://npmjs.org/discord-rich-presence) | JavaScript |
| [drpc4k](https://github.com/Bluexin/drpc4k) | [Kotlin](https://kotlinlang.org/) |
| [lua-discordRPC](https://github.com/pfirsich/lua-discordRPC) | LuaJIT (FFI) |
| [pypresence](https://github.com/qwertyquerty/pypresence) | [Python](https://python.org/) |
| [SwordRPC](https://github.com/Azoy/SwordRPC) | [Swift](https://swift.org) |

63
3rdparty/discord-rpc/discord_backoff.h vendored Normal file
View File

@@ -0,0 +1,63 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_BACKOFF_H
#define DISCORD_BACKOFF_H
#include <algorithm>
#include <random>
#include <cstdint>
#include <ctime>
namespace discord_rpc {
struct Backoff {
int64_t minAmount;
int64_t maxAmount;
int64_t current;
int fails;
std::mt19937_64 randGenerator;
std::uniform_real_distribution<> randDistribution;
double rand01() { return randDistribution(randGenerator); }
Backoff(int64_t min, int64_t max)
: minAmount(min), maxAmount(max), current(min), fails(0), randGenerator(static_cast<uint64_t>(time(0))) {
}
void reset() {
fails = 0;
current = minAmount;
}
int64_t nextDelay() {
++fails;
int64_t delay = static_cast<int64_t>(static_cast<double>(current) * 2.0 * rand01());
current = std::min(current + delay, maxAmount);
return current;
}
};
} // namespace discord_rpc
#endif // DISCORD_BACKOFF_H

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_CONNECTION_H
#define DISCORD_CONNECTION_H
// This is to wrap the platform specific kinds of connect/read/write.
#include <cstdlib>
namespace discord_rpc {
// not really connectiony, but need per-platform
int GetProcessId();
struct BaseConnection {
static BaseConnection *Create();
static void Destroy(BaseConnection *&);
bool isOpen = false;
bool Open();
bool Close();
bool Write(const void *data, size_t length);
bool Read(void *data, size_t length);
};
} // namespace discord_rpc
#endif // DISCORD_CONNECTION_H

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include "discord_connection.h"
#include <cerrno>
#include <fcntl.h>
#include <cstdio>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
namespace discord_rpc {
int GetProcessId() {
return ::getpid();
}
struct BaseConnectionUnix : public BaseConnection {
int sock { -1 };
};
static BaseConnectionUnix Connection;
static sockaddr_un PipeAddr {};
#ifdef MSG_NOSIGNAL
static int MsgFlags = MSG_NOSIGNAL;
#else
static int MsgFlags = 0;
#endif
static const char *GetTempPath() {
const char *temp = getenv("XDG_RUNTIME_DIR");
temp = temp ? temp : getenv("TMPDIR");
temp = temp ? temp : getenv("TMP");
temp = temp ? temp : getenv("TEMP");
temp = temp ? temp : "/tmp";
return temp;
}
BaseConnection *BaseConnection::Create() {
PipeAddr.sun_family = AF_UNIX;
return &Connection;
}
void BaseConnection::Destroy(BaseConnection *&c) {
auto self = reinterpret_cast<BaseConnectionUnix*>(c);
self->Close();
c = nullptr;
}
bool BaseConnection::Open() {
const char *tempPath = GetTempPath();
auto self = reinterpret_cast<BaseConnectionUnix*>(this);
self->sock = socket(AF_UNIX, SOCK_STREAM, 0);
if (self->sock == -1) {
return false;
}
fcntl(self->sock, F_SETFL, O_NONBLOCK);
#ifdef SO_NOSIGPIPE
int optval = 1;
setsockopt(self->sock, SOL_SOCKET, SO_NOSIGPIPE, &optval, sizeof(optval));
#endif
for (int pipeNum = 0; pipeNum < 10; ++pipeNum) {
snprintf(PipeAddr.sun_path, sizeof(PipeAddr.sun_path), "%s/discord-ipc-%d", tempPath, pipeNum);
int err = connect(self->sock, reinterpret_cast<const sockaddr*>(&PipeAddr), sizeof(PipeAddr));
if (err == 0) {
self->isOpen = true;
return true;
}
}
self->Close();
return false;
}
bool BaseConnection::Close() {
auto self = reinterpret_cast<BaseConnectionUnix *>(this);
if (self->sock == -1) {
return false;
}
close(self->sock);
self->sock = -1;
self->isOpen = false;
return true;
}
bool BaseConnection::Write(const void *data, size_t length) {
auto self = reinterpret_cast<BaseConnectionUnix*>(this);
if (self->sock == -1) {
return false;
}
ssize_t sentBytes = send(self->sock, data, length, MsgFlags);
if (sentBytes < 0) {
Close();
}
return sentBytes == static_cast<ssize_t>(length);
}
bool BaseConnection::Read(void *data, size_t length) {
auto self = reinterpret_cast<BaseConnectionUnix*>(this);
if (self->sock == -1) {
return false;
}
long res = recv(self->sock, data, length, MsgFlags);
if (res < 0) {
if (errno == EAGAIN) {
return false;
}
Close();
}
else if (res == 0) {
Close();
}
return static_cast<size_t>(res) == length;
}
} // namespace discord_rpc

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include "discord_connection.h"
#define WIN32_LEAN_AND_MEAN
#define NOMCX
#define NOSERVICE
#define NOIME
#include <cassert>
#include <windows.h>
namespace discord_rpc {
int GetProcessId() {
return static_cast<int>(::GetCurrentProcessId());
}
struct BaseConnectionWin : public BaseConnection {
HANDLE pipe { INVALID_HANDLE_VALUE };
};
static BaseConnectionWin Connection;
BaseConnection *BaseConnection::Create() {
return &Connection;
}
void BaseConnection::Destroy(BaseConnection *&c) {
auto self = reinterpret_cast<BaseConnectionWin*>(c);
self->Close();
c = nullptr;
}
bool BaseConnection::Open() {
wchar_t pipeName[] { L"\\\\?\\pipe\\discord-ipc-0" };
const size_t pipeDigit = sizeof(pipeName) / sizeof(wchar_t) - 2;
pipeName[pipeDigit] = L'0';
auto self = reinterpret_cast<BaseConnectionWin *>(this);
for (;;) {
self->pipe = ::CreateFileW(pipeName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
if (self->pipe != INVALID_HANDLE_VALUE) {
self->isOpen = true;
return true;
}
auto lastError = GetLastError();
if (lastError == ERROR_FILE_NOT_FOUND) {
if (pipeName[pipeDigit] < L'9') {
pipeName[pipeDigit]++;
continue;
}
}
else if (lastError == ERROR_PIPE_BUSY) {
if (!WaitNamedPipeW(pipeName, 10000)) {
return false;
}
continue;
}
return false;
}
}
bool BaseConnection::Close() {
auto self = reinterpret_cast<BaseConnectionWin *>(this);
::CloseHandle(self->pipe);
self->pipe = INVALID_HANDLE_VALUE;
self->isOpen = false;
return true;
}
bool BaseConnection::Write(const void *data, size_t length) {
if (length == 0) {
return true;
}
auto self = reinterpret_cast<BaseConnectionWin *>(this);
assert(self);
if (!self) {
return false;
}
if (self->pipe == INVALID_HANDLE_VALUE) {
return false;
}
assert(data);
if (!data) {
return false;
}
const DWORD bytesLength = static_cast<DWORD>(length);
DWORD bytesWritten = 0;
return ::WriteFile(self->pipe, data, bytesLength, &bytesWritten, nullptr) == TRUE && bytesWritten == bytesLength;
}
bool BaseConnection::Read(void *data, size_t length) {
assert(data);
if (!data) {
return false;
}
auto self = reinterpret_cast<BaseConnectionWin *>(this);
assert(self);
if (!self) {
return false;
}
if (self->pipe == INVALID_HANDLE_VALUE) {
return false;
}
DWORD bytesAvailable = 0;
if (::PeekNamedPipe(self->pipe, nullptr, 0, nullptr, &bytesAvailable, nullptr)) {
if (bytesAvailable >= length) {
DWORD bytesToRead = static_cast<DWORD>(length);
DWORD bytesRead = 0;
if (::ReadFile(self->pipe, data, bytesToRead, &bytesRead, nullptr) == TRUE) {
assert(bytesToRead == bytesRead);
return true;
}
else {
Close();
}
}
}
else {
Close();
}
return false;
}
} // namespace discord_rpc

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_MSG_QUEUE_H
#define DISCORD_MSG_QUEUE_H
#include <atomic>
// A simple queue. No locks, but only works with a single thread as producer and a single thread as
// a consumer. Mutex up as needed.
namespace discord_rpc {
template<typename ElementType, std::size_t QueueSize>
class MsgQueue {
ElementType queue_[QueueSize];
std::atomic_uint nextAdd_ { 0 };
std::atomic_uint nextSend_ { 0 };
std::atomic_uint pendingSends_ { 0 };
public:
MsgQueue() {}
ElementType *GetNextAddMessage() {
// if we are falling behind, bail
if (pendingSends_.load() >= QueueSize) {
return nullptr;
}
auto index = (nextAdd_++) % QueueSize;
return &queue_[index];
}
void CommitAdd() { ++pendingSends_; }
bool HavePendingSends() const { return pendingSends_.load() != 0; }
ElementType *GetNextSendMessage() {
auto index = (nextSend_++) % QueueSize;
return &queue_[index];
}
void CommitSend() { --pendingSends_; }
};
} // namespace discord_rpc
#endif // DISCORD_MSG_QUEUE_H

37
3rdparty/discord-rpc/discord_register.h vendored Normal file
View File

@@ -0,0 +1,37 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_REGISTER_H
#define DISCORD_REGISTER_H
#ifdef __cplusplus
extern "C" {
#endif
void Discord_Register(const char *applicationId, const char *command);
#ifdef __cplusplus
}
#endif
#endif // DISCORD_REGISTER_H

View File

@@ -0,0 +1,120 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include "discord_rpc.h"
#include "discord_register.h"
#include <cstdio>
#include <errno.h>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
namespace {
static bool Mkdir(const char *path) {
int result = mkdir(path, 0755);
if (result == 0) {
return true;
}
if (errno == EEXIST) {
return true;
}
return false;
}
} // namespace
// We want to register games so we can run them from Discord client as discord-<appid>://
extern "C" void Discord_Register(const char *applicationId, const char *command) {
// Add a desktop file and update some mime handlers so that xdg-open does the right thing.
const char *home = getenv("HOME");
if (!home) {
return;
}
char exePath[1024]{};
if (!command || !command[0]) {
const ssize_t size = readlink("/proc/self/exe", exePath, sizeof(exePath));
if (size <= 0 || size >= static_cast<ssize_t>(sizeof(exePath))) {
return;
}
exePath[size] = '\0';
command = exePath;
}
constexpr char desktopFileFormat[] = "[Desktop Entry]\n"
"Name=Game %s\n"
"Exec=%s %%u\n" // note: it really wants that %u in there
"Type=Application\n"
"NoDisplay=true\n"
"Categories=Discord;Games;\n"
"MimeType=x-scheme-handler/discord-%s;\n";
char desktopFile[2048]{};
int fileLen = snprintf(desktopFile, sizeof(desktopFile), desktopFileFormat, applicationId, command, applicationId);
if (fileLen <= 0) {
return;
}
char desktopFilename[256]{};
(void)snprintf(desktopFilename, sizeof(desktopFilename), "/discord-%s.desktop", applicationId);
char desktopFilePath[1024]{};
(void)snprintf(desktopFilePath, sizeof(desktopFilePath), "%s/.local", home);
if (!Mkdir(desktopFilePath)) {
return;
}
strcat(desktopFilePath, "/share");
if (!Mkdir(desktopFilePath)) {
return;
}
strcat(desktopFilePath, "/applications");
if (!Mkdir(desktopFilePath)) {
return;
}
strcat(desktopFilePath, desktopFilename);
FILE *fp = fopen(desktopFilePath, "w");
if (fp) {
fwrite(desktopFile, 1, fileLen, fp);
fclose(fp);
}
else {
return;
}
char xdgMimeCommand[1024]{};
snprintf(xdgMimeCommand,
sizeof(xdgMimeCommand),
"xdg-mime default discord-%s.desktop x-scheme-handler/discord-%s",
applicationId,
applicationId);
if (system(xdgMimeCommand) < 0) {
fprintf(stderr, "Failed to register mime handler\n");
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include <stdio.h>
#include <sys/stat.h>
#import <AppKit/AppKit.h>
#include "discord_register.h"
static void RegisterCommand(const char *applicationId, const char *command) {
// There does not appear to be a way to register arbitrary commands on OSX, so instead we'll save the command
// to a file in the Discord config path, and when it is needed, Discord can try to load the file there, open
// the command therein (will pass to js's window.open, so requires a url-like thing)
// Note: will not work for sandboxed apps
NSString *home = NSHomeDirectory();
if (!home) {
return;
}
NSString *path = [[[[[[home stringByAppendingPathComponent:@"Library"]
stringByAppendingPathComponent:@"Application Support"]
stringByAppendingPathComponent:@"discord"]
stringByAppendingPathComponent:@"games"]
stringByAppendingPathComponent:[NSString stringWithUTF8String:applicationId]]
stringByAppendingPathExtension:@"json"];
[[NSFileManager defaultManager] createDirectoryAtPath:[path stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
NSString *jsonBuffer = [NSString stringWithFormat:@"{\"command\": \"%s\"}", command];
[jsonBuffer writeToFile:path atomically:NO encoding:NSUTF8StringEncoding error:nil];
}
static void RegisterURL(const char *applicationId) {
char url[256];
snprintf(url, sizeof(url), "discord-%s", applicationId);
CFStringRef cfURL = CFStringCreateWithCString(NULL, url, kCFStringEncodingUTF8);
NSString* myBundleId = [[NSBundle mainBundle] bundleIdentifier];
if (!myBundleId) {
fprintf(stderr, "No bundle id found\n");
return;
}
NSURL* myURL = [[NSBundle mainBundle] bundleURL];
if (!myURL) {
fprintf(stderr, "No bundle url found\n");
return;
}
OSStatus status = LSSetDefaultHandlerForURLScheme(cfURL, (__bridge CFStringRef)myBundleId);
if (status != noErr) {
fprintf(stderr, "Error in LSSetDefaultHandlerForURLScheme: %d\n", (int)status);
return;
}
status = LSRegisterURL((__bridge CFURLRef)myURL, true);
if (status != noErr) {
fprintf(stderr, "Error in LSRegisterURL: %d\n", (int)status);
}
}
void Discord_Register(const char *applicationId, const char *command) {
if (command) {
RegisterCommand(applicationId, command);
}
else {
// raii lite
@autoreleasepool {
RegisterURL(applicationId);
}
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include "discord_rpc.h"
#include "discord_register.h"
#define WIN32_LEAN_AND_MEAN
#define NOMCX
#define NOSERVICE
#define NOIME
#include <windows.h>
#include <psapi.h>
#include <cstdio>
/**
* Updated fixes for MinGW and WinXP
* This block is written the way it does not involve changing the rest of the code
* Checked to be compiling
* 1) strsafe.h belongs to Windows SDK and cannot be added to MinGW
* #include guarded, functions redirected to <string.h> substitutes
* 2) RegSetKeyValueW and LSTATUS are not declared in <winreg.h>
* The entire function is rewritten
*/
#ifdef __MINGW32__
# include <wchar.h>
/// strsafe.h fixes
static HRESULT StringCbPrintfW(LPWSTR pszDest, size_t cbDest, LPCWSTR pszFormat, ...) {
HRESULT ret;
va_list va;
va_start(va, pszFormat);
cbDest /= 2; // Size is divided by 2 to convert from bytes to wide characters - causes segfault
// othervise
ret = vsnwprintf(pszDest, cbDest, pszFormat, va);
pszDest[cbDest - 1] = 0; // Terminate the string in case a buffer overflow; -1 will be returned
va_end(va);
return ret;
}
#else
# include <cwchar>
# include <strsafe.h>
#endif // __MINGW32__
/// winreg.h fixes
#ifndef LSTATUS
# define LSTATUS LONG
#endif
#ifdef RegSetKeyValueW
# undefine RegSetKeyValueW
#endif
#define RegSetKeyValueW regset
static LSTATUS regset(HKEY hkey, LPCWSTR subkey, LPCWSTR name, DWORD type, const void *data, DWORD len) {
HKEY htkey = hkey, hsubkey = nullptr;
LSTATUS ret;
if (subkey && subkey[0]) {
if ((ret = RegCreateKeyExW(hkey, subkey, 0, 0, 0, KEY_ALL_ACCESS, 0, &hsubkey, 0)) !=
ERROR_SUCCESS)
return ret;
htkey = hsubkey;
}
ret = RegSetValueExW(htkey, name, 0, type, static_cast<const BYTE*>(data), len);
if (hsubkey && hsubkey != hkey)
RegCloseKey(hsubkey);
return ret;
}
static void Discord_RegisterW(const wchar_t *applicationId, const wchar_t *command) {
// https://msdn.microsoft.com/en-us/library/aa767914(v=vs.85).aspx
// we want to register games so we can run them as discord-<appid>://
// Update the HKEY_CURRENT_USER, because it doesn't seem to require special permissions.
wchar_t exeFilePath[MAX_PATH]{};
DWORD exeLen = GetModuleFileNameW(nullptr, exeFilePath, MAX_PATH);
wchar_t openCommand[1024]{};
if (command && command[0]) {
StringCbPrintfW(openCommand, sizeof(openCommand), L"%s", command);
}
else {
// StringCbCopyW(openCommand, sizeof(openCommand), exeFilePath);
StringCbPrintfW(openCommand, sizeof(openCommand), L"%s", exeFilePath);
}
wchar_t protocolName[64]{};
StringCbPrintfW(protocolName, sizeof(protocolName), L"discord-%s", applicationId);
wchar_t protocolDescription[128]{};
StringCbPrintfW(protocolDescription, sizeof(protocolDescription), L"URL:Run game %s protocol", applicationId);
wchar_t urlProtocol = 0;
wchar_t keyName[256]{};
StringCbPrintfW(keyName, sizeof(keyName), L"Software\\Classes\\%s", protocolName);
HKEY key;
auto status = RegCreateKeyExW(HKEY_CURRENT_USER, keyName, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr);
if (status != ERROR_SUCCESS) {
fprintf(stderr, "Error creating key\n");
return;
}
DWORD len;
LSTATUS result;
len = static_cast<DWORD>(lstrlenW(protocolDescription) + 1);
result = RegSetKeyValueW(key, nullptr, nullptr, REG_SZ, protocolDescription, len * sizeof(wchar_t));
if (FAILED(result)) {
fprintf(stderr, "Error writing description\n");
}
len = static_cast<DWORD>(lstrlenW(protocolDescription) + 1);
result = RegSetKeyValueW(key, nullptr, L"URL Protocol", REG_SZ, &urlProtocol, sizeof(wchar_t));
if (FAILED(result)) {
fprintf(stderr, "Error writing description\n");
}
result = RegSetKeyValueW(key, L"DefaultIcon", nullptr, REG_SZ, exeFilePath, (exeLen + 1) * sizeof(wchar_t));
if (FAILED(result)) {
fprintf(stderr, "Error writing icon\n");
}
len = static_cast<DWORD>(lstrlenW(openCommand) + 1);
result = RegSetKeyValueW(key, L"shell\\open\\command", nullptr, REG_SZ, openCommand, len * sizeof(wchar_t));
if (FAILED(result)) {
fprintf(stderr, "Error writing command\n");
}
RegCloseKey(key);
}
extern "C" void Discord_Register(const char *applicationId, const char *command) {
wchar_t appId[32]{};
MultiByteToWideChar(CP_UTF8, 0, applicationId, -1, appId, 32);
wchar_t openCommand[1024]{};
const wchar_t *wcommand = nullptr;
if (command && command[0]) {
const auto commandBufferLen = sizeof(openCommand) / sizeof(*openCommand);
MultiByteToWideChar(CP_UTF8, 0, command, -1, openCommand, commandBufferLen);
wcommand = openCommand;
}
Discord_RegisterW(appId, wcommand);
}

510
3rdparty/discord-rpc/discord_rpc.cpp vendored Normal file
View File

@@ -0,0 +1,510 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include <atomic>
#include <chrono>
#include <mutex>
#include <condition_variable>
#include <thread>
#include "discord_rpc.h"
#include "discord_backoff.h"
#include "discord_register.h"
#include "discord_msg_queue.h"
#include "discord_rpc_connection.h"
#include "discord_serialization.h"
using namespace discord_rpc;
static void Discord_UpdateConnection();
namespace {
constexpr size_t MaxMessageSize { 16 * 1024 };
constexpr size_t MessageQueueSize { 8 };
constexpr size_t JoinQueueSize { 8 };
struct QueuedMessage {
size_t length;
char buffer[MaxMessageSize];
void Copy(const QueuedMessage &other) {
length = other.length;
if (length) {
memcpy(buffer, other.buffer, length);
}
}
};
struct User {
// snowflake (64bit int), turned into a ascii decimal string, at most 20 chars +1 null
// terminator = 21
char userId[32];
// 32 unicode glyphs is max name size => 4 bytes per glyph in the worst case, +1 for null
// terminator = 129
char username[344];
// 4 decimal digits + 1 null terminator = 5
char discriminator[8];
// optional 'a_' + md5 hex digest (32 bytes) + null terminator = 35
char avatar[128];
// Rounded way up because I'm paranoid about games breaking from future changes in these sizes
};
static RpcConnection *Connection { nullptr };
static DiscordEventHandlers QueuedHandlers {};
static DiscordEventHandlers Handlers {};
static std::atomic_bool WasJustConnected { false };
static std::atomic_bool WasJustDisconnected { false };
static std::atomic_bool GotErrorMessage { false };
static std::atomic_bool WasJoinGame { false };
static std::atomic_bool WasSpectateGame { false };
static std::atomic_bool UpdatePresence { false };
static char JoinGameSecret[256];
static char SpectateGameSecret[256];
static int LastErrorCode { 0 };
static char LastErrorMessage[256];
static int LastDisconnectErrorCode { 0 };
static char LastDisconnectErrorMessage[256];
static std::mutex PresenceMutex;
static std::mutex HandlerMutex;
static QueuedMessage QueuedPresence {};
static MsgQueue<QueuedMessage, MessageQueueSize> SendQueue;
static MsgQueue<User, JoinQueueSize> JoinAskQueue;
static User connectedUser;
// We want to auto connect, and retry on failure, but not as fast as possible. This does expoential backoff from 0.5 seconds to 1 minute
static Backoff ReconnectTimeMs(500, 60 * 1000);
static auto NextConnect = std::chrono::system_clock::now();
static int Pid { 0 };
static int Nonce { 1 };
class IoThreadHolder {
private:
std::atomic_bool keepRunning { true };
std::mutex waitForIOMutex;
std::condition_variable waitForIOActivity;
std::thread ioThread;
public:
void Start() {
keepRunning.store(true);
ioThread = std::thread([&]() {
const std::chrono::duration<int64_t, std::milli> maxWait { 500LL };
Discord_UpdateConnection();
while (keepRunning.load()) {
std::unique_lock<std::mutex> lock(waitForIOMutex);
waitForIOActivity.wait_for(lock, maxWait);
Discord_UpdateConnection();
}
});
}
void Notify() { waitForIOActivity.notify_all(); }
void Stop() {
keepRunning.exchange(false);
Notify();
if (ioThread.joinable()) {
ioThread.join();
}
}
~IoThreadHolder() { Stop(); }
};
static IoThreadHolder *IoThread { nullptr };
static void UpdateReconnectTime() {
NextConnect = std::chrono::system_clock::now() + std::chrono::duration<int64_t, std::milli> { ReconnectTimeMs.nextDelay() };
}
static void SignalIOActivity() {
if (IoThread != nullptr) {
IoThread->Notify();
}
}
static bool RegisterForEvent(const char *evtName) {
auto qmessage = SendQueue.GetNextAddMessage();
if (qmessage) {
qmessage->length = JsonWriteSubscribeCommand(qmessage->buffer, sizeof(qmessage->buffer), Nonce++, evtName);
SendQueue.CommitAdd();
SignalIOActivity();
return true;
}
return false;
}
static bool DeregisterForEvent(const char *evtName) {
auto qmessage = SendQueue.GetNextAddMessage();
if (qmessage) {
qmessage->length = JsonWriteUnsubscribeCommand(qmessage->buffer, sizeof(qmessage->buffer), Nonce++, evtName);
SendQueue.CommitAdd();
SignalIOActivity();
return true;
}
return false;
}
} // namespace
static void Discord_UpdateConnection() {
if (!Connection) {
return;
}
if (!Connection->IsOpen()) {
if (std::chrono::system_clock::now() >= NextConnect) {
UpdateReconnectTime();
Connection->Open();
}
}
else {
// reads
for (;;) {
JsonDocument message;
if (!Connection->Read(message)) {
break;
}
const char *evtName = GetStrMember(&message, "evt");
const char *nonce = GetStrMember(&message, "nonce");
if (nonce) {
// in responses only -- should use to match up response when needed.
if (evtName && strcmp(evtName, "ERROR") == 0) {
auto data = GetObjMember(&message, "data");
LastErrorCode = GetIntMember(data, "code");
StringCopy(LastErrorMessage, GetStrMember(data, "message", ""));
GotErrorMessage.store(true);
}
}
else {
// should have evt == name of event, optional data
if (evtName == nullptr) {
continue;
}
auto data = GetObjMember(&message, "data");
if (strcmp(evtName, "ACTIVITY_JOIN") == 0) {
auto secret = GetStrMember(data, "secret");
if (secret) {
StringCopy(JoinGameSecret, secret);
WasJoinGame.store(true);
}
}
else if (strcmp(evtName, "ACTIVITY_SPECTATE") == 0) {
auto secret = GetStrMember(data, "secret");
if (secret) {
StringCopy(SpectateGameSecret, secret);
WasSpectateGame.store(true);
}
}
else if (strcmp(evtName, "ACTIVITY_JOIN_REQUEST") == 0) {
auto user = GetObjMember(data, "user");
auto userId = GetStrMember(user, "id");
auto username = GetStrMember(user, "username");
auto avatar = GetStrMember(user, "avatar");
auto joinReq = JoinAskQueue.GetNextAddMessage();
if (userId && username && joinReq) {
StringCopy(joinReq->userId, userId);
StringCopy(joinReq->username, username);
auto discriminator = GetStrMember(user, "discriminator");
if (discriminator) {
StringCopy(joinReq->discriminator, discriminator);
}
if (avatar) {
StringCopy(joinReq->avatar, avatar);
}
else {
joinReq->avatar[0] = 0;
}
JoinAskQueue.CommitAdd();
}
}
}
}
// writes
if (UpdatePresence.exchange(false) && QueuedPresence.length) {
QueuedMessage local;
{
std::lock_guard<std::mutex> guard(PresenceMutex);
local.Copy(QueuedPresence);
}
if (!Connection->Write(local.buffer, local.length)) {
// if we fail to send, requeue
std::lock_guard<std::mutex> guard(PresenceMutex);
QueuedPresence.Copy(local);
UpdatePresence.exchange(true);
}
}
while (SendQueue.HavePendingSends()) {
auto qmessage = SendQueue.GetNextSendMessage();
Connection->Write(qmessage->buffer, qmessage->length);
SendQueue.CommitSend();
}
}
}
extern "C" void Discord_Initialize(const char *applicationId, DiscordEventHandlers *handlers, const int autoRegister) {
IoThread = new (std::nothrow) IoThreadHolder();
if (IoThread == nullptr) {
return;
}
if (autoRegister) {
Discord_Register(applicationId, nullptr);
}
Pid = GetProcessId();
{
std::lock_guard<std::mutex> guard(HandlerMutex);
if (handlers) {
QueuedHandlers = *handlers;
}
else {
QueuedHandlers = {};
}
Handlers = {};
}
if (Connection) {
return;
}
Connection = RpcConnection::Create(applicationId);
Connection->onConnect = [](JsonDocument &readyMessage) {
Discord_UpdateHandlers(&QueuedHandlers);
if (QueuedPresence.length > 0) {
UpdatePresence.exchange(true);
SignalIOActivity();
}
auto data = GetObjMember(&readyMessage, "data");
auto user = GetObjMember(data, "user");
auto userId = GetStrMember(user, "id");
auto username = GetStrMember(user, "username");
auto avatar = GetStrMember(user, "avatar");
if (userId && username) {
StringCopy(connectedUser.userId, userId);
StringCopy(connectedUser.username, username);
auto discriminator = GetStrMember(user, "discriminator");
if (discriminator) {
StringCopy(connectedUser.discriminator, discriminator);
}
if (avatar) {
StringCopy(connectedUser.avatar, avatar);
}
else {
connectedUser.avatar[0] = 0;
}
}
WasJustConnected.exchange(true);
ReconnectTimeMs.reset();
};
Connection->onDisconnect = [](int err, const char *message) {
LastDisconnectErrorCode = err;
StringCopy(LastDisconnectErrorMessage, message);
WasJustDisconnected.exchange(true);
UpdateReconnectTime();
};
IoThread->Start();
}
extern "C" void Discord_Shutdown() {
if (!Connection) {
return;
}
Connection->onConnect = nullptr;
Connection->onDisconnect = nullptr;
Handlers = {};
QueuedPresence.length = 0;
UpdatePresence.exchange(false);
if (IoThread != nullptr) {
IoThread->Stop();
delete IoThread;
IoThread = nullptr;
}
RpcConnection::Destroy(Connection);
}
extern "C" void Discord_UpdatePresence(const DiscordRichPresence *presence) {
{
std::lock_guard<std::mutex> guard(PresenceMutex);
QueuedPresence.length = JsonWriteRichPresenceObj(QueuedPresence.buffer, sizeof(QueuedPresence.buffer), Nonce++, Pid, presence);
UpdatePresence.exchange(true);
}
SignalIOActivity();
}
extern "C" void Discord_ClearPresence(void) {
Discord_UpdatePresence(nullptr);
}
extern "C" void Discord_Respond(const char *userId, /* DISCORD_REPLY_ */ int reply) {
// if we are not connected, let's not batch up stale messages for later
if (!Connection || !Connection->IsOpen()) {
return;
}
auto qmessage = SendQueue.GetNextAddMessage();
if (qmessage) {
qmessage->length = JsonWriteJoinReply(qmessage->buffer, sizeof(qmessage->buffer), userId, reply, Nonce++);
SendQueue.CommitAdd();
SignalIOActivity();
}
}
extern "C" void Discord_RunCallbacks() {
// Note on some weirdness: internally we might connect, get other signals, disconnect any number
// of times inbetween calls here. Externally, we want the sequence to seem sane, so any other
// signals are book-ended by calls to ready and disconnect.
if (!Connection) {
return;
}
const bool wasDisconnected = WasJustDisconnected.exchange(false);
const bool isConnected = Connection->IsOpen();
if (isConnected) {
// if we are connected, disconnect cb first
std::lock_guard<std::mutex> guard(HandlerMutex);
if (wasDisconnected && Handlers.disconnected) {
Handlers.disconnected(LastDisconnectErrorCode, LastDisconnectErrorMessage);
}
}
if (WasJustConnected.exchange(false)) {
std::lock_guard<std::mutex> guard(HandlerMutex);
if (Handlers.ready) {
DiscordUser du { connectedUser.userId, connectedUser.username, connectedUser.discriminator, connectedUser.avatar };
Handlers.ready(&du);
}
}
if (GotErrorMessage.exchange(false)) {
std::lock_guard<std::mutex> guard(HandlerMutex);
if (Handlers.errored) {
Handlers.errored(LastErrorCode, LastErrorMessage);
}
}
if (WasJoinGame.exchange(false)) {
std::lock_guard<std::mutex> guard(HandlerMutex);
if (Handlers.joinGame) {
Handlers.joinGame(JoinGameSecret);
}
}
if (WasSpectateGame.exchange(false)) {
std::lock_guard<std::mutex> guard(HandlerMutex);
if (Handlers.spectateGame) {
Handlers.spectateGame(SpectateGameSecret);
}
}
// Right now this batches up any requests and sends them all in a burst; I could imagine a world
// where the implementer would rather sequentially accept/reject each one before the next invite
// is sent. I left it this way because I could also imagine wanting to process these all and
// maybe show them in one common dialog and/or start fetching the avatars in parallel, and if
// not it should be trivial for the implementer to make a queue themselves.
while (JoinAskQueue.HavePendingSends()) {
const auto req = JoinAskQueue.GetNextSendMessage();
{
std::lock_guard<std::mutex> guard(HandlerMutex);
if (Handlers.joinRequest) {
DiscordUser du { req->userId, req->username, req->discriminator, req->avatar };
Handlers.joinRequest(&du);
}
}
JoinAskQueue.CommitSend();
}
if (!isConnected) {
// if we are not connected, disconnect message last
std::lock_guard<std::mutex> guard(HandlerMutex);
if (wasDisconnected && Handlers.disconnected) {
Handlers.disconnected(LastDisconnectErrorCode, LastDisconnectErrorMessage);
}
}
}
extern "C" void Discord_UpdateHandlers(DiscordEventHandlers *newHandlers) {
if (newHandlers) {
#define HANDLE_EVENT_REGISTRATION(handler_name, event) \
if (!Handlers.handler_name && newHandlers->handler_name) { \
RegisterForEvent(event); \
} \
else if (Handlers.handler_name && !newHandlers->handler_name) { \
DeregisterForEvent(event); \
}
std::lock_guard<std::mutex> guard(HandlerMutex);
HANDLE_EVENT_REGISTRATION(joinGame, "ACTIVITY_JOIN")
HANDLE_EVENT_REGISTRATION(spectateGame, "ACTIVITY_SPECTATE")
HANDLE_EVENT_REGISTRATION(joinRequest, "ACTIVITY_JOIN_REQUEST")
#undef HANDLE_EVENT_REGISTRATION
Handlers = *newHandlers;
}
else {
std::lock_guard<std::mutex> guard(HandlerMutex);
Handlers = {};
}
}

93
3rdparty/discord-rpc/discord_rpc.h vendored Normal file
View File

@@ -0,0 +1,93 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_RPC_H
#define DISCORD_RPC_H
#include <cstdint>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct DiscordRichPresence {
int type;
const char *name; /* max 128 bytes */
const char *state; /* max 128 bytes */
const char *details; /* max 128 bytes */
int64_t startTimestamp;
int64_t endTimestamp;
const char *largeImageKey; /* max 32 bytes */
const char *largeImageText; /* max 128 bytes */
const char *smallImageKey; /* max 32 bytes */
const char *smallImageText; /* max 128 bytes */
const char *partyId; /* max 128 bytes */
int partySize;
int partyMax;
int partyPrivacy;
const char *matchSecret; /* max 128 bytes */
const char *joinSecret; /* max 128 bytes */
const char *spectateSecret; /* max 128 bytes */
int8_t instance;
} DiscordRichPresence;
typedef struct DiscordUser {
const char *userId;
const char *username;
const char *discriminator;
const char *avatar;
} DiscordUser;
typedef struct DiscordEventHandlers {
void (*ready)(const DiscordUser *request);
void (*disconnected)(int errorCode, const char *message);
void (*errored)(int errorCode, const char *message);
void (*joinGame)(const char *joinSecret);
void (*spectateGame)(const char *spectateSecret);
void (*joinRequest)(const DiscordUser *request);
} DiscordEventHandlers;
#define DISCORD_REPLY_NO 0
#define DISCORD_REPLY_YES 1
#define DISCORD_REPLY_IGNORE 2
#define DISCORD_PARTY_PRIVATE 0
#define DISCORD_PARTY_PUBLIC 1
void Discord_Initialize(const char *applicationId, DiscordEventHandlers *handlers, const int autoRegister);
void Discord_Shutdown(void);
// checks for incoming messages, dispatches callbacks
void Discord_RunCallbacks(void);
void Discord_UpdatePresence(const DiscordRichPresence *presence);
void Discord_ClearPresence(void);
void Discord_Respond(const char *userid, /* DISCORD_REPLY_ */ int reply);
void Discord_UpdateHandlers(DiscordEventHandlers *handlers);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif // DISCORD_RPC_H

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include "discord_rpc_connection.h"
#include "discord_serialization.h"
namespace discord_rpc {
static const int RpcVersion = 1;
static RpcConnection Instance;
RpcConnection *RpcConnection::Create(const char *applicationId) {
Instance.connection = BaseConnection::Create();
StringCopy(Instance.appId, applicationId);
return &Instance;
}
void RpcConnection::Destroy(RpcConnection *&c) {
c->Close();
BaseConnection::Destroy(c->connection);
c = nullptr;
}
void RpcConnection::Open() {
if (state == State::Connected) {
return;
}
if (state == State::Disconnected && !connection->Open()) {
return;
}
if (state == State::SentHandshake) {
JsonDocument message;
if (Read(message)) {
auto cmd = GetStrMember(&message, "cmd");
auto evt = GetStrMember(&message, "evt");
if (cmd && evt && !strcmp(cmd, "DISPATCH") && !strcmp(evt, "READY")) {
state = State::Connected;
if (onConnect) {
onConnect(message);
}
}
}
}
else {
sendFrame.opcode = Opcode::Handshake;
sendFrame.length = static_cast<uint32_t>(JsonWriteHandshakeObj(sendFrame.message, sizeof(sendFrame.message), RpcVersion, appId));
if (connection->Write(&sendFrame, sizeof(MessageFrameHeader) + sendFrame.length)) {
state = State::SentHandshake;
}
else {
Close();
}
}
}
void RpcConnection::Close() {
if (onDisconnect && (state == State::Connected || state == State::SentHandshake)) {
onDisconnect(lastErrorCode, lastErrorMessage);
}
connection->Close();
state = State::Disconnected;
}
bool RpcConnection::Write(const void *data, size_t length) {
sendFrame.opcode = Opcode::Frame;
memcpy(sendFrame.message, data, length);
sendFrame.length = static_cast<uint32_t>(length);
if (!connection->Write(&sendFrame, sizeof(MessageFrameHeader) + length)) {
Close();
return false;
}
return true;
}
bool RpcConnection::Read(JsonDocument &message) {
if (state != State::Connected && state != State::SentHandshake) {
return false;
}
MessageFrame readFrame{};
for (;;) {
bool didRead = connection->Read(&readFrame, sizeof(MessageFrameHeader));
if (!didRead) {
if (!connection->isOpen) {
lastErrorCode = static_cast<int>(ErrorCode::PipeClosed);
StringCopy(lastErrorMessage, "Pipe closed");
Close();
}
return false;
}
if (readFrame.length > 0) {
didRead = connection->Read(readFrame.message, readFrame.length);
if (!didRead) {
lastErrorCode = static_cast<int>(ErrorCode::ReadCorrupt);
StringCopy(lastErrorMessage, "Partial data in frame");
Close();
return false;
}
readFrame.message[readFrame.length] = 0;
}
switch (readFrame.opcode) {
case Opcode::Close: {
message.ParseInsitu(readFrame.message);
lastErrorCode = GetIntMember(&message, "code");
StringCopy(lastErrorMessage, GetStrMember(&message, "message", ""));
Close();
return false;
}
case Opcode::Frame:
message.ParseInsitu(readFrame.message);
return true;
case Opcode::Ping:
readFrame.opcode = Opcode::Pong;
if (!connection->Write(&readFrame, sizeof(MessageFrameHeader) + readFrame.length)) {
Close();
}
break;
case Opcode::Pong:
break;
case Opcode::Handshake:
default:
// something bad happened
lastErrorCode = static_cast<int>(ErrorCode::ReadCorrupt);
StringCopy(lastErrorMessage, "Bad ipc frame");
Close();
return false;
}
}
}
} // namespace discord_rpc

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_RPC_CONNECTION_H
#define DISCORD_RPC_CONNECTION_H
#include "discord_connection.h"
#include "discord_serialization.h"
namespace discord_rpc {
// I took this from the buffer size libuv uses for named pipes; I suspect ours would usually be much smaller.
constexpr size_t MaxRpcFrameSize = 64 * 1024;
struct RpcConnection {
enum class ErrorCode : int {
Success = 0,
PipeClosed = 1,
ReadCorrupt = 2,
};
enum class Opcode : uint32_t {
Handshake = 0,
Frame = 1,
Close = 2,
Ping = 3,
Pong = 4,
};
struct MessageFrameHeader {
Opcode opcode;
uint32_t length;
};
struct MessageFrame : public MessageFrameHeader {
char message[MaxRpcFrameSize - sizeof(MessageFrameHeader)];
};
enum class State : uint32_t {
Disconnected,
SentHandshake,
AwaitingResponse,
Connected,
};
BaseConnection *connection { nullptr };
State state { State::Disconnected };
void (*onConnect)(JsonDocument &message) { nullptr };
void (*onDisconnect)(int errorCode, const char *message) { nullptr };
char appId[64] {};
int lastErrorCode { 0 };
char lastErrorMessage[256] {};
RpcConnection::MessageFrame sendFrame;
static RpcConnection *Create(const char *applicationId);
static void Destroy(RpcConnection *&);
inline bool IsOpen() const { return state == State::Connected; }
void Open();
void Close();
bool Write(const void *data, size_t length);
bool Read(JsonDocument &message);
};
} // namespace discord_rpc
#endif // DISCORD_RPC_CONNECTION_H

View File

@@ -0,0 +1,282 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#include "discord_serialization.h"
#include "discord_connection.h"
#include "discord_rpc.h"
namespace discord_rpc {
template<typename T>
void NumberToString(char *dest, T number) {
if (!number) {
*dest++ = '0';
*dest++ = 0;
return;
}
if (number < 0) {
*dest++ = '-';
number = -number;
}
char temp[32];
int place = 0;
while (number) {
auto digit = number % 10;
number = number / 10;
temp[place++] = '0' + static_cast<char>(digit);
}
for (--place; place >= 0; --place) {
*dest++ = temp[place];
}
*dest = 0;
}
// it's ever so slightly faster to not have to strlen the key
template<typename T>
void WriteKey(JsonWriter &w, T &k) {
w.Key(k, sizeof(T) - 1);
}
struct WriteObject {
JsonWriter &writer;
WriteObject(JsonWriter &w)
: writer(w) {
writer.StartObject();
}
template<typename T>
WriteObject(JsonWriter &w, T &name)
: writer(w) {
WriteKey(writer, name);
writer.StartObject();
}
~WriteObject() { writer.EndObject(); }
};
struct WriteArray {
JsonWriter &writer;
template<typename T>
WriteArray(JsonWriter &w, T &name)
: writer(w) {
WriteKey(writer, name);
writer.StartArray();
}
~WriteArray() { writer.EndArray(); }
};
template<typename T>
void WriteOptionalString(JsonWriter &w, T &k, const char *value) {
if (value && value[0]) {
w.Key(k, sizeof(T) - 1);
w.String(value);
}
}
static void JsonWriteNonce(JsonWriter &writer, const int nonce) {
WriteKey(writer, "nonce");
char nonceBuffer[32];
NumberToString(nonceBuffer, nonce);
writer.String(nonceBuffer);
}
size_t JsonWriteRichPresenceObj(char *dest, const size_t maxLen, const int nonce, const int pid, const DiscordRichPresence *presence) {
JsonWriter writer(dest, maxLen);
{
WriteObject top(writer);
JsonWriteNonce(writer, nonce);
WriteKey(writer, "cmd");
writer.String("SET_ACTIVITY");
{
WriteObject args(writer, "args");
WriteKey(writer, "pid");
writer.Int(pid);
if (presence != nullptr) {
WriteObject activity(writer, "activity");
if (presence->type >= 0 && presence->type <= 5) {
WriteKey(writer, "type");
writer.Int(presence->type);
}
WriteOptionalString(writer, "name", presence->name);
WriteOptionalString(writer, "state", presence->state);
WriteOptionalString(writer, "details", presence->details);
if (presence->startTimestamp || presence->endTimestamp) {
WriteObject timestamps(writer, "timestamps");
if (presence->startTimestamp) {
WriteKey(writer, "start");
writer.Int64(presence->startTimestamp);
}
if (presence->endTimestamp) {
WriteKey(writer, "end");
writer.Int64(presence->endTimestamp);
}
}
if ((presence->largeImageKey && presence->largeImageKey[0]) ||
(presence->largeImageText && presence->largeImageText[0]) ||
(presence->smallImageKey && presence->smallImageKey[0]) ||
(presence->smallImageText && presence->smallImageText[0])) {
WriteObject assets(writer, "assets");
WriteOptionalString(writer, "large_image", presence->largeImageKey);
WriteOptionalString(writer, "large_text", presence->largeImageText);
WriteOptionalString(writer, "small_image", presence->smallImageKey);
WriteOptionalString(writer, "small_text", presence->smallImageText);
}
if ((presence->partyId && presence->partyId[0]) || presence->partySize ||
presence->partyMax || presence->partyPrivacy) {
WriteObject party(writer, "party");
WriteOptionalString(writer, "id", presence->partyId);
if (presence->partySize && presence->partyMax) {
WriteArray size(writer, "size");
writer.Int(presence->partySize);
writer.Int(presence->partyMax);
}
if (presence->partyPrivacy) {
WriteKey(writer, "privacy");
writer.Int(presence->partyPrivacy);
}
}
if ((presence->matchSecret && presence->matchSecret[0]) ||
(presence->joinSecret && presence->joinSecret[0]) ||
(presence->spectateSecret && presence->spectateSecret[0])) {
WriteObject secrets(writer, "secrets");
WriteOptionalString(writer, "match", presence->matchSecret);
WriteOptionalString(writer, "join", presence->joinSecret);
WriteOptionalString(writer, "spectate", presence->spectateSecret);
}
writer.Key("instance");
writer.Bool(presence->instance != 0);
}
}
}
return writer.Size();
}
size_t JsonWriteHandshakeObj(char *dest, size_t maxLen, int version, const char *applicationId) {
JsonWriter writer(dest, maxLen);
{
WriteObject obj(writer);
WriteKey(writer, "v");
writer.Int(version);
WriteKey(writer, "client_id");
writer.String(applicationId);
}
return writer.Size();
}
size_t JsonWriteSubscribeCommand(char *dest, size_t maxLen, int nonce, const char *evtName) {
JsonWriter writer(dest, maxLen);
{
WriteObject obj(writer);
JsonWriteNonce(writer, nonce);
WriteKey(writer, "cmd");
writer.String("SUBSCRIBE");
WriteKey(writer, "evt");
writer.String(evtName);
}
return writer.Size();
}
size_t JsonWriteUnsubscribeCommand(char *dest, size_t maxLen, int nonce, const char *evtName) {
JsonWriter writer(dest, maxLen);
{
WriteObject obj(writer);
JsonWriteNonce(writer, nonce);
WriteKey(writer, "cmd");
writer.String("UNSUBSCRIBE");
WriteKey(writer, "evt");
writer.String(evtName);
}
return writer.Size();
}
size_t JsonWriteJoinReply(char *dest, size_t maxLen, const char *userId, const int reply, const int nonce) {
JsonWriter writer(dest, maxLen);
{
WriteObject obj(writer);
WriteKey(writer, "cmd");
if (reply == DISCORD_REPLY_YES) {
writer.String("SEND_ACTIVITY_JOIN_INVITE");
}
else {
writer.String("CLOSE_ACTIVITY_JOIN_REQUEST");
}
WriteKey(writer, "args");
{
WriteObject args(writer);
WriteKey(writer, "user_id");
writer.String(userId);
}
JsonWriteNonce(writer, nonce);
}
return writer.Size();
}
} // namespace discord_rpc

View File

@@ -0,0 +1,213 @@
/*
* Copyright 2017 Discord, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
* of the Software, and to permit persons to whom the Software is furnished to do
* so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
#ifndef DISCORD_SERIALIZATION_H
#define DISCORD_SERIALIZATION_H
#include <rapidjson/document.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
struct DiscordRichPresence;
namespace discord_rpc {
// if only there was a standard library function for this
template<size_t Len>
inline size_t StringCopy(char (&dest)[Len], const char *src) {
if (!src || !Len) {
return 0;
}
size_t copied;
char *out = dest;
for (copied = 1; *src && copied < Len; ++copied) {
*out++ = *src++;
}
*out = 0;
return copied - 1;
}
size_t JsonWriteHandshakeObj(char *dest, size_t maxLen, int version, const char *applicationId);
// Commands
size_t JsonWriteRichPresenceObj(char *dest, const size_t maxLen, const int nonce, const int pid, const DiscordRichPresence *presence);
size_t JsonWriteSubscribeCommand(char *dest, size_t maxLen, int nonce, const char *evtName);
size_t JsonWriteUnsubscribeCommand(char *dest, size_t maxLen, int nonce, const char *evtName);
size_t JsonWriteJoinReply(char *dest, size_t maxLen, const char *userId, int reply, int nonce);
// I want to use as few allocations as I can get away with, and to do that with RapidJson, you need
// to supply some of your own allocators for stuff rather than use the defaults
class LinearAllocator {
public:
char *buffer_;
char *end_;
LinearAllocator() {
assert(0); // needed for some default case in rapidjson, should not use
}
LinearAllocator(char *buffer, size_t size)
: buffer_(buffer), end_(buffer + size) {
}
static const bool kNeedFree = false;
void *Malloc(size_t size) {
char *res = buffer_;
buffer_ += size;
if (buffer_ > end_) {
buffer_ = res;
return nullptr;
}
return res;
}
void *Realloc(void *originalPtr, size_t originalSize, size_t newSize) {
if (newSize == 0) {
return nullptr;
}
// allocate how much you need in the first place
assert(!originalPtr && !originalSize);
// unused parameter warning
(void)(originalPtr);
(void)(originalSize);
return Malloc(newSize);
}
static void Free(void *ptr) {
/* shrug */
(void)ptr;
}
};
template<size_t Size>
class FixedLinearAllocator : public LinearAllocator {
public:
char fixedBuffer_[Size];
FixedLinearAllocator()
: LinearAllocator(fixedBuffer_, Size) {
}
static const bool kNeedFree = false;
};
// wonder why this isn't a thing already, maybe I missed it
class DirectStringBuffer {
public:
using Ch = char;
char *buffer_;
char *end_;
char *current_;
DirectStringBuffer(char *buffer, size_t maxLen)
: buffer_(buffer), end_(buffer + maxLen), current_(buffer) {
}
void Put(char c) {
if (current_ < end_) {
*current_++ = c;
}
}
void Flush() {}
size_t GetSize() const { return static_cast<size_t>(current_ - buffer_); }
};
using MallocAllocator = rapidjson::CrtAllocator;
using PoolAllocator = rapidjson::MemoryPoolAllocator<MallocAllocator>;
using UTF8 = rapidjson::UTF8<char>;
// Writer appears to need about 16 bytes per nested object level (with 64bit size_t)
using StackAllocator = FixedLinearAllocator<2048>;
constexpr size_t WriterNestingLevels = 2048 / (2 * sizeof(size_t));
using JsonWriterBase =
rapidjson::Writer<DirectStringBuffer, UTF8, UTF8, StackAllocator, rapidjson::kWriteNoFlags>;
class JsonWriter : public JsonWriterBase {
public:
DirectStringBuffer stringBuffer_;
StackAllocator stackAlloc_;
JsonWriter(char *dest, size_t maxLen)
: JsonWriterBase(stringBuffer_, &stackAlloc_, WriterNestingLevels), stringBuffer_(dest, maxLen), stackAlloc_() {
}
size_t Size() const { return stringBuffer_.GetSize(); }
};
using JsonDocumentBase = rapidjson::GenericDocument<UTF8, PoolAllocator, StackAllocator>;
class JsonDocument : public JsonDocumentBase {
public:
static const int kDefaultChunkCapacity = 32 * 1024;
// json parser will use this buffer first, then allocate more if needed; I seriously doubt we
// send any messages that would use all of this, though.
char parseBuffer_[32 * 1024];
MallocAllocator mallocAllocator_;
PoolAllocator poolAllocator_;
StackAllocator stackAllocator_;
JsonDocument()
: JsonDocumentBase(rapidjson::kObjectType,
&poolAllocator_,
sizeof(stackAllocator_.fixedBuffer_),
&stackAllocator_),
poolAllocator_(parseBuffer_, sizeof(parseBuffer_), kDefaultChunkCapacity, &mallocAllocator_), stackAllocator_() {
}
};
using JsonValue = rapidjson::GenericValue<UTF8, PoolAllocator>;
inline JsonValue *GetObjMember(JsonValue *obj, const char *name) {
if (obj) {
auto member = obj->FindMember(name);
if (member != obj->MemberEnd() && member->value.IsObject()) {
return &member->value;
}
}
return nullptr;
}
inline int GetIntMember(JsonValue *obj, const char *name, int notFoundDefault = 0) {
if (obj) {
auto member = obj->FindMember(name);
if (member != obj->MemberEnd() && member->value.IsInt()) {
return member->value.GetInt();
}
}
return notFoundDefault;
}
inline const char *GetStrMember(JsonValue *obj, const char *name, const char *notFoundDefault = nullptr) {
if (obj) {
auto member = obj->FindMember(name);
if (member != obj->MemberEnd() && member->value.IsString()) {
return member->value.GetString();
}
}
return notFoundDefault;
}
} // namespace discord_rpc
#endif // DISCORD_SERIALIZATION_H

View File

@@ -1,11 +0,0 @@
cmake_minimum_required(VERSION 3.13)
set(SOURCES KDSingleApplication/src/kdsingleapplication.cpp KDSingleApplication/src/kdsingleapplication_localsocket.cpp)
set(HEADERS KDSingleApplication/src/kdsingleapplication.h KDSingleApplication/src/kdsingleapplication_localsocket_p.h)
qt_wrap_cpp(MOC ${HEADERS})
add_library(kdsingleapplication STATIC ${SOURCES} ${MOC})
if(NOT MSVC)
target_compile_options(kdsingleapplication PRIVATE -Wno-missing-declarations)
endif()
target_compile_definitions(kdsingleapplication PRIVATE -DKDSINGLEAPPLICATION_STATIC_BUILD)
target_include_directories(kdsingleapplication PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(kdsingleapplication PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Network)

View File

@@ -81,7 +81,7 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(MSVC)
list(APPEND COMPILE_OPTIONS /MP)
list(APPEND COMPILE_OPTIONS -MP -W4 -wd4702)
else()
list(APPEND COMPILE_OPTIONS
$<$<COMPILE_LANGUAGE:C>:-std=c11>
@@ -128,7 +128,10 @@ add_definitions(
)
if(WIN32)
add_definitions(-DUNICODE)
add_definitions(
-DUNICODE
-DNOMINMAX
)
endif()
if(BUILD_WERROR)
@@ -207,34 +210,31 @@ endif()
find_package(GTest)
pkg_check_modules(LIBSPARSEHASH IMPORTED_TARGET libsparsehash)
find_package(RapidJSON)
set(QT_VERSION_MAJOR 6)
set(QT_MIN_VERSION 6.4.0)
set(QT_DEFAULT_MAJOR_VERSION ${QT_VERSION_MAJOR})
set(QT_COMPONENTS Core Concurrent Gui Widgets Network Sql)
set(QT_OPTIONAL_COMPONENTS LinguistTools Test)
set(QT_OPTIONAL_COMPONENTS GuiPrivate LinguistTools Test)
if(UNIX AND NOT APPLE)
list(APPEND QT_OPTIONAL_COMPONENTS DBus)
endif()
set(QT_NO_PRIVATE_MODULE_WARNING ON)
find_package(Qt${QT_VERSION_MAJOR} ${QT_MIN_VERSION} COMPONENTS ${QT_COMPONENTS} REQUIRED OPTIONAL_COMPONENTS ${QT_OPTIONAL_COMPONENTS})
if(TARGET "Qt${QT_VERSION_MAJOR}::GuiPrivate")
set(QT_GUI_PRIVATE_FOUND ON)
endif()
if(Qt${QT_VERSION_MAJOR}DBus_FOUND)
set(DBUS_FOUND ON)
endif()
if(X11_FOUND)
find_path(QPA_QPLATFORMNATIVEINTERFACE_H qpa/qplatformnativeinterface.h PATHS ${Qt${QT_VERSION_MAJOR}Gui_INCLUDE_DIRS} ${Qt${QT_VERSION_MAJOR}Gui_PRIVATE_INCLUDE_DIRS})
if(NOT QPA_QPLATFORMNATIVEINTERFACE_H)
find_path(QPA_QPLATFORMNATIVEINTERFACE_H ${Qt${QT_VERSION_MAJOR}Gui_VERSION}/QtGui/qpa/qplatformnativeinterface.h PATHS ${Qt${QT_VERSION_MAJOR}Gui_INCLUDE_DIRS} ${Qt${QT_VERSION_MAJOR}Gui_PRIVATE_INCLUDE_DIRS})
endif()
if(QPA_QPLATFORMNATIVEINTERFACE_H)
set(HAVE_QPA_QPLATFORMNATIVEINTERFACE_H ON)
message(STATUS "Have qpa/qplatformnativeinterface.h header.")
else()
message(STATUS "Missing qpa/qplatformnativeinterface.h header.")
endif()
# Check for QX11Application (Qt 6 compiled with XCB).
set(CMAKE_REQUIRED_FLAGS "-std=c++17")
set(CMAKE_REQUIRED_LIBRARIES Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui)
@@ -249,23 +249,9 @@ if(X11_FOUND)
)
unset(CMAKE_REQUIRED_FLAGS)
unset(CMAKE_REQUIRED_LIBRARIES)
endif()
# SingleApplication
set(KDSINGLEAPPLICATION_NAME "KDSingleApplication-qt${QT_VERSION_MAJOR}")
find_package(${KDSINGLEAPPLICATION_NAME} 1.1.0)
if(TARGET KDAB::kdsingleapplication)
set(KDSINGLEAPPLICATION_VERSION "${KDSingleApplication-qt6_VERSION}")
message(STATUS "Using system KDSingleApplication (Version ${KDSINGLEAPPLICATION_VERSION})")
set(SINGLEAPPLICATION_LIBRARIES KDAB::kdsingleapplication)
else()
message(STATUS "Using 3rdparty KDSingleApplication")
add_subdirectory(3rdparty/kdsingleapplication)
set(SINGLEAPPLICATION_INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/kdsingleapplication/KDSingleApplication/src)
set(SINGLEAPPLICATION_LIBRARIES kdsingleapplication)
add_definitions(-DKDSINGLEAPPLICATION_STATIC_BUILD)
endif()
find_package(KDSingleApplication-qt${QT_VERSION_MAJOR} 1.1.0 REQUIRED)
if(APPLE)
find_library(SPARKLE Sparkle)
@@ -372,6 +358,18 @@ if(APPLE OR WIN32)
)
endif()
optional_component(QPA_QPLATFORMNATIVEINTERFACE ON "QPA Platform Native Interface"
DEPENDS "Qt Gui Private" QT_GUI_PRIVATE_FOUND
)
optional_component(STREAMTAGREADER ON "Stream tagreader"
DEPENDS "sparsehash" LIBSPARSEHASH_FOUND
)
optional_component(DISCORD_RPC ON "Discord Rich Presence"
DEPENDS "RapidJSON" RapidJSON_FOUND
)
if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ)
set(HAVE_CHROMAPRINT ON)
endif()
@@ -458,6 +456,9 @@ set(SOURCES
src/core/songmimedata.cpp
src/core/platforminterface.cpp
src/core/standardpaths.cpp
src/core/httpbaserequest.cpp
src/core/jsonbaserequest.cpp
src/core/oauthenticator.cpp
src/utilities/strutils.cpp
src/utilities/envutils.cpp
@@ -478,6 +479,7 @@ set(SOURCES
src/utilities/screenutils.cpp
src/utilities/textencodingutils.cpp
src/utilities/coveroptions.cpp
src/utilities/musixmatchprovider.cpp
src/tagreader/tagreaderclient.cpp
src/tagreader/tagreaderresult.cpp
@@ -587,6 +589,8 @@ set(SOURCES
src/playlist/playlistfilter.cpp
src/playlist/playlistheader.cpp
src/playlist/playlistitem.cpp
src/playlist/songplaylistitem.cpp
src/playlist/streamplaylistitem.cpp
src/playlist/playlistitemmimedata.cpp
src/playlist/playlistlistcontainer.cpp
src/playlist/playlistlistmodel.cpp
@@ -600,7 +604,6 @@ set(SOURCES
src/playlist/playlistview.cpp
src/playlist/playlistproxystyle.cpp
src/playlist/songloaderinserter.cpp
src/playlist/songplaylistitem.cpp
src/playlist/dynamicplaylistcontrols.cpp
src/playlist/playlistundocommandbase.cpp
src/playlist/playlistundocommandinsertitems.cpp
@@ -679,7 +682,6 @@ set(SOURCES
src/lyrics/htmllyricsprovider.cpp
src/lyrics/ovhlyricsprovider.cpp
src/lyrics/lololyricsprovider.cpp
src/lyrics/geniuslyricsprovider.cpp
src/lyrics/musixmatchlyricsprovider.cpp
src/lyrics/chartlyricsprovider.cpp
src/lyrics/songlyricscomlyricsprovider.cpp
@@ -688,8 +690,6 @@ set(SOURCES
src/lyrics/letraslyricsprovider.cpp
src/lyrics/lyricfindlyricsprovider.cpp
src/providers/musixmatchprovider.cpp
src/settings/settingsdialog.cpp
src/settings/settingspage.cpp
src/settings/settingsitemdelegate.cpp
@@ -752,7 +752,7 @@ set(SOURCES
src/streaming/streamingservices.cpp
src/streaming/streamingservice.cpp
src/streaming/streamplaylistitem.cpp
src/streaming/streamserviceplaylistitem.cpp
src/streaming/streamingsearchview.cpp
src/streaming/streamingsearchmodel.cpp
src/streaming/streamingsearchsortmodel.cpp
@@ -770,7 +770,7 @@ set(SOURCES
src/radios/radioview.cpp
src/radios/radioviewcontainer.cpp
src/radios/radioservice.cpp
src/radios/radioplaylistitem.cpp
src/radios/radiostreamplaylistitem.cpp
src/radios/radiochannel.cpp
src/radios/somafmservice.cpp
src/radios/radioparadiseservice.cpp
@@ -852,6 +852,9 @@ set(HEADERS
src/core/stylesheetloader.h
src/core/localredirectserver.h
src/core/songmimedata.h
src/core/httpbaserequest.h
src/core/jsonbaserequest.h
src/core/oauthenticator.h
src/tagreader/tagreaderclient.h
src/tagreader/tagreaderreply.h
@@ -976,7 +979,6 @@ set(HEADERS
src/lyrics/htmllyricsprovider.h
src/lyrics/ovhlyricsprovider.h
src/lyrics/lololyricsprovider.h
src/lyrics/geniuslyricsprovider.h
src/lyrics/musixmatchlyricsprovider.h
src/lyrics/chartlyricsprovider.h
src/lyrics/songlyricscomlyricsprovider.h
@@ -1234,6 +1236,16 @@ optional_source(WIN32
src/core/windows7thumbbar.h
)
optional_source(HAVE_STREAMTAGREADER
SOURCES src/tagreader/streamtagreader.cpp src/tagreader/tagreaderreadstreamrequest.cpp src/tagreader/tagreaderreadstreamreply.cpp
HEADERS src/tagreader/tagreaderreadstreamreply.h
)
optional_source(HAVE_DISCORD_RPC
SOURCES src/discord/richpresence.cpp
HEADERS src/discord/richpresence.h
)
if(HAVE_GLOBALSHORTCUTS)
optional_source(HAVE_GLOBALSHORTCUTS
@@ -1478,6 +1490,11 @@ if(LINUX AND LSB_RELEASE_EXEC AND DPKG_BUILDPACKAGE)
add_subdirectory(debian)
endif()
if(HAVE_DISCORD_RPC)
add_subdirectory(3rdparty/discord-rpc)
target_include_directories(strawberry_lib PUBLIC 3rdparty/discord-rpc)
endif()
if(HAVE_TRANSLATIONS)
qt_add_lupdate(strawberry_lib TS_FILES "${CMAKE_SOURCE_DIR}/src/translations/strawberry_en_US.ts" OPTIONS -locations none -no-ui-lines -no-obsolete)
file(GLOB_RECURSE ts_files ${CMAKE_SOURCE_DIR}/src/translations/*.ts)
@@ -1499,10 +1516,6 @@ if(SINGLEAPPLICATION_INCLUDE_DIRS)
target_include_directories(strawberry_lib SYSTEM PUBLIC ${SINGLEAPPLICATION_INCLUDE_DIRS})
endif()
if(HAVE_QPA_QPLATFORMNATIVEINTERFACE_H)
target_include_directories(strawberry_lib SYSTEM PUBLIC ${Qt${QT_VERSION_MAJOR}Gui_PRIVATE_INCLUDE_DIRS})
endif()
target_link_libraries(strawberry_lib PUBLIC
${CMAKE_THREAD_LIBS_INIT}
$<$<BOOL:${HAVE_BACKTRACE}>:${Backtrace_LIBRARIES}>
@@ -1523,8 +1536,10 @@ target_link_libraries(strawberry_lib PUBLIC
Qt${QT_VERSION_MAJOR}::Network
Qt${QT_VERSION_MAJOR}::Sql
$<$<BOOL:${HAVE_DBUS}>:Qt${QT_VERSION_MAJOR}::DBus>
$<$<BOOL:${HAVE_QPA_QPLATFORMNATIVEINTERFACE}>:Qt${QT_VERSION_MAJOR}::GuiPrivate>
ICU::uc
ICU::i18n
$<$<BOOL:${HAVE_STREAMTAGREADER}>:PkgConfig::LIBSPARSEHASH>
$<$<BOOL:${HAVE_ALSA}>:ALSA::ALSA>
$<$<BOOL:${HAVE_PULSE}>:PkgConfig::LIBPULSE>
$<$<BOOL:${HAVE_CHROMAPRINT}>:PkgConfig::CHROMAPRINT>
@@ -1539,7 +1554,7 @@ target_link_libraries(strawberry_lib PUBLIC
$<$<BOOL:${HAVE_QTSPARKLE}>:qtsparkle-qt${QT_VERSION_MAJOR}::qtsparkle>
$<$<BOOL:${WIN32}>:dsound dwmapi getopt-win::getopt>
$<$<BOOL:${MSVC}>:WindowsApp>
${SINGLEAPPLICATION_LIBRARIES}
KDAB::kdsingleapplication
)
if(APPLE)
@@ -1558,6 +1573,10 @@ if(APPLE)
endif()
endif()
if(HAVE_DISCORD_RPC)
target_link_libraries(strawberry_lib PRIVATE discord-rpc)
endif()
target_link_libraries(strawberry PUBLIC strawberry_lib)
if(NOT APPLE)

View File

@@ -2,6 +2,59 @@ Strawberry Music Player
=======================
ChangeLog
Version 1.2.10 (2025.04.18):
Bugfixes:
* Fixed Discord rich presence showing bogus artist and album.
* Fixed incorrect ID3v2 comment tag.
* (macOS|Windows MSVC) Fixed stuck playback of some streams.
Enhancements:
* Removed Genius lyrics (longer working properly because of website changes).
* (macOS|Windows MSVC) Added back Spotify
Version 1.2.9 (2025.04.08):
Bugfixes:
* Fixed subsonic parse error (#1719).
* Fixed Deezer cover provider parse error (#1716).
* Fixed last.fm import progress.
* (Windows|MinGW) Switched from winpthreads to win32 threads, winpthreads are no longer working with Qt as of version 6.9 (QTBUG-131892).
Enhancements:
* Added option to disable playbin3.
Version 1.2.8 (2025.04.05):
Bugfixes:
* Added "HI_RES_LOSSLESS" for Tidal quality setting.
* Increased backend settings device lineedit height.
* Possible fix for KGlobalAccel shortcuts sometimes not working.
Enhancements:
* Removed deprecated Tidal username/password login.
* Turned off "Grey out unavailable songs in playlists on startup" by default.
* Added support for reading tags from streams.
* Added tooltips in equalizer, backend and appearance settings.
* Added full tag support for AIFF including embedded covers.
* Use card ID instead of index for ALSA devices.
* Removed KDSingleApplication from 3rdparty.
* Support arbitrarily large EBU R 128 loudness normalization.
New features:
* Added Discord rich presence support.
Version 1.2.7 (2025.01.31):
Bugfixes:
* Fixed strawberry exiting when clicking tray icon.
* Fixed Clementine import script errors.
* Disabled OSD Pretty on Wayland since it's not working properly.
Enhancements:
* Only maximize error dialog if Strawberry is the active window (#1627).
* Added QPA Platform Native Interface as optional component.
Version 1.2.6 (2025.01.17):
Bugfixes:

View File

@@ -4,7 +4,7 @@
[![Patreon](https://img.shields.io/badge/patreon-donate-green.svg)](https://patreon.com/jonaskvinge)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/jonaskvinge)
Strawberry is a music player and music collection organizer. It is a fork of Clementine released in 2018 aimed at music collectors and audiophiles. It's written in C++ using the Qt toolkit.
Strawberry is a music player and music collection organizer. It is a fork of Clementine released in 2018 aimed at music collectors and audiophiles. It's written in C++ using the Qt framework.
![Browse](https://raw.githubusercontent.com/strawberrymusicplayer/strawberry/master/data/screenshot/screenshot.png)
@@ -53,13 +53,15 @@ Funding developers is a way to contribute to open source projects you appreciate
* Edit tags on audio files
* Fetch tags from MusicBrainz
* Album cover art from [Last.fm](https://www.last.fm/), [Musicbrainz](https://musicbrainz.org/), [Discogs](https://www.discogs.com/), [Musixmatch](https://www.musixmatch.com/), [Deezer](https://www.deezer.com/), [Tidal](https://www.tidal.com/), [Qobuz](https://www.qobuz.com/) and [Spotify](https://www.spotify.com/)
* Song lyrics from [Genius](https://genius.com/), [Musixmatch](https://www.musixmatch.com/), [ChartLyrics](http://www.chartlyrics.com/), [lyrics.ovh](https://lyrics.ovh/), [lololyrics.com](https://www.lololyrics.com/), [songlyrics.com](https://www.songlyrics.com/), [azlyrics.com](https://www.azlyrics.com/) and [elyrics.net](https://www.elyrics.net/)
* Song lyrics from [Musixmatch](https://www.musixmatch.com/), [ChartLyrics](http://www.chartlyrics.com/), [lyrics.ovh](https://lyrics.ovh/), [lololyrics.com](https://www.lololyrics.com/), [songlyrics.com](https://www.songlyrics.com/), [azlyrics.com](https://www.azlyrics.com/) and [elyrics.net](https://www.elyrics.net/)
* Support for multiple backends
* Audio analyzer
* Audio equalizer
* Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic
* Scrobbler with support for [Last.fm](https://www.last.fm/), [Libre.fm](https://libre.fm/) and [ListenBrainz](https://listenbrainz.org/)
* Subsonic, Tidal, Spotify and Qobuz streaming support
* Streaming from Subsonic compatible servers
* Unofficial Tidal, Spotify and Qobuz integration
* Discord rich presence
It has so far been tested to work on Linux, OpenBSD, FreeBSD, macOS and Windows.
@@ -70,7 +72,7 @@ It has so far been tested to work on Linux, OpenBSD, FreeBSD, macOS and Windows.
To build Strawberry from source you need the following installed on your system with the additional development packages/headers:
* [CMake](https://cmake.org/)
* [CMake 3.13 or higher](https://cmake.org/)
* C/C++ compiler ([GCC](https://gcc.gnu.org/), [Clang](https://clang.llvm.org/) or [MSVC](https://visualstudio.microsoft.com/vs/features/cplusplus/))
* [pkg-config](https://www.freedesktop.org/wiki/Software/pkg-config/) or [pkgconf](https://github.com/pkgconf/pkgconf)
* [Boost](https://www.boost.org/)
@@ -81,7 +83,7 @@ To build Strawberry from source you need the following installed on your system
* [GStreamer](https://gstreamer.freedesktop.org/)
* [TagLib 1.12 or higher](https://www.taglib.org/)
* [ICU](https://unicode-org.github.io/icu/)
* [KDSingleApplication](https://github.com/KDAB/KDSingleApplication)
* [KDSingleApplication 1.1.0 or higher](https://github.com/KDAB/KDSingleApplication)
Optional dependencies:
@@ -92,25 +94,24 @@ Optional dependencies:
* MTP devices: [libmtp](http://libmtp.sourceforge.net/)
* iPod Classic devices: [libgpod](http://www.gtkpod.org/libgpod/)
* EBU R 128 loudness normalization [libebur128](https://github.com/jiixyj/libebur128)
* Discord rich presence [RapidJSON](https://rapidjson.org/)
You should also install the gstreamer plugins base and good, and optionally bad, ugly and libav to support all audio formats.
### :wrench: Compiling from source
### :wrench: Build from source
### Get the code:
git clone --recursive https://github.com/strawberrymusicplayer/strawberry
### Compile and install:
### Build and install:
cd strawberry
mkdir build
cd build
cmake ..
make -j $(nproc)
sudo make install
cmake -S . -B build
cmake --build build --parallel $(nproc)
sudo cmake --install build
To compile on Windows with Visual Studio 2019 or 2022, see https://github.com/strawberrymusicplayer/strawberry-msvc
To build on Windows with Visual Studio 2022, see https://github.com/strawberrymusicplayer/strawberry-msvc
### :penguin: Packaging status

View File

@@ -1,9 +1,9 @@
set(STRAWBERRY_VERSION_MAJOR 1)
set(STRAWBERRY_VERSION_MINOR 2)
set(STRAWBERRY_VERSION_PATCH 6)
set(STRAWBERRY_VERSION_PATCH 10)
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
set(INCLUDE_GIT_REVISION ON)
set(INCLUDE_GIT_REVISION OFF)
set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}")

9
debian/control vendored
View File

@@ -15,11 +15,14 @@ Build-Depends: debhelper-compat (= 12),
libpulse-dev,
libtag1-dev,
libicu-dev,
libxkbcommon-dev,
qt6-base-dev,
qt6-base-private-dev,
qt6-base-dev-tools,
qt6-tools-dev,
qt6-tools-dev-tools,
qt6-l10n-tools,
libkdsingleapplication-qt6-dev,
libgstreamer1.0-dev,
libgstreamer-plugins-base1.0-dev,
libcdio-dev,
@@ -27,7 +30,9 @@ Build-Depends: debhelper-compat (= 12),
libmtp-dev,
libchromaprint-dev,
libfftw3-dev,
libebur128-dev
libebur128-dev,
libsparsehash-dev,
rapidjson-dev
Standards-Version: 4.7.0
Package: strawberry
@@ -55,7 +60,7 @@ Description: music player and music collection organizer
- Edit tags on audio files
- Automatically retrieve tags from MusicBrainz
- Album cover art from Last.fm, Musicbrainz, Discogs, Musixmatch, Deezer, Tidal, Qobuz and Spotify
- Song lyrics from Genius, Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com and elyrics.net
- Song lyrics from Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com and elyrics.net
- Audio analyzer
- Audio equalizer
- Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic

4
debian/rules vendored
View File

@@ -3,6 +3,10 @@
export DH_VERBOSE=1
export DEB_BUILD_MAINT_OPTIONS=hardening=+all
override_dh_auto_configure:
dh_auto_configure -- \
-DBUILD_WERROR=ON
override_dh_installchangelogs:
dh_installchangelogs Changelog

View File

@@ -2,7 +2,7 @@
# Strawberry Music Player
# Copyright 2020, Jonas Kvinge <jonas@jkvinge.net>
# 2021 Alexey Vazhnov
# Copyright 2021, Alexey Vazhnov
#
# Strawberry is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -19,7 +19,7 @@
# SPDX-License-Identifier: GPL-3.0-only
# Based on https://github.com/strawberrymusicplayer/strawberry/wiki/Import-collection-library-and-playlists-data-from-Clementine
# Based on https://wiki.strawberrymusicplayer.org/wiki/Import_collection_library_and_playlists_from_Clementine
set -o nounset
set -o errexit
@@ -35,8 +35,8 @@ test -f "$FILE_DST" || { echo "No such file: $FILE_DST"; exit 1; }
echo "Will try to copy information from $FILE_SRC to $FILE_DST."
echo
echo 'This script will **delete all information** from Strawberry database!'
read -r -p 'Do you want to continue? (the only YES is accepted) ' answer
echo 'This script will **delete all data** from the Strawberry database!'
read -r -p 'Do you want to continue? (Only YES is accepted) ' answer
if [ "$answer" != "YES" ]; then exit 1; fi
# 'heredoc' with substitution of variables, see `man bash`, "Here Documents":
@@ -62,9 +62,9 @@ INSERT INTO strawberry.subdirectories (directory_id, path, mtime) SELECT directo
INSERT INTO strawberry.songs (ROWID, title, album, artist, albumartist, track, disc, year, originalyear, genre, compilation, composer, performer, grouping, comment, lyrics, beginning, length, bitrate, samplerate, directory_id, url, filetype, filesize, mtime, ctime, unavailable, playcount, skipcount, lastplayed, compilation_detected, compilation_on, compilation_off, compilation_effective, art_automatic, art_manual, effective_albumartist, effective_originalyear, cue_path, rating)
SELECT ROWID, title, album, artist, albumartist, track, disc, year, originalyear, genre, compilation, composer, performer, grouping, comment, lyrics, beginning, length, bitrate, samplerate, directory, filename, filetype, filesize, mtime, ctime, unavailable, playcount, skipcount, lastplayed, sampler, forced_compilation_on, forced_compilation_off, effective_compilation, art_automatic, art_manual, effective_albumartist, effective_originalyear, cue_path, rating FROM clementine.songs WHERE unavailable = 0;
UPDATE strawberry.songs SET source = 2;
UPDATE strawberry.songs SET artist_id = "";
UPDATE strawberry.songs SET album_id = "";
UPDATE strawberry.songs SET song_id = "";
UPDATE strawberry.songs SET artist_id = '';
UPDATE strawberry.songs SET album_id = '';
UPDATE strawberry.songs SET song_id = '';
/* Import playlists */
@@ -140,7 +140,7 @@ SELECT ROWID,
bitrate,
samplerate,
directory,
filename,
CASE WHEN filename IS NULL THEN '' ELSE filename END,
filetype,
filesize,
mtime,
@@ -162,16 +162,9 @@ SELECT ROWID,
UPDATE strawberry.playlist_items SET source = 2;
UPDATE strawberry.playlist_items SET type = 2;
UPDATE strawberry.playlist_items SET artist_id = "";
UPDATE strawberry.playlist_items SET album_id = "";
UPDATE strawberry.playlist_items SET song_id = "";
/* Recreate the FTS tables */
DELETE FROM strawberry.songs_fts;
INSERT INTO strawberry.songs_fts (ROWID, ftstitle, ftsalbum, ftsartist, ftsalbumartist, ftscomposer, ftsperformer, ftsgrouping, ftsgenre, ftscomment)
SELECT ROWID, title, album, artist, albumartist, composer, performer, grouping, genre, comment
FROM strawberry.songs;
UPDATE strawberry.playlist_items SET artist_id = '';
UPDATE strawberry.playlist_items SET album_id = '';
UPDATE strawberry.playlist_items SET song_id = '';
EOF

View File

@@ -31,7 +31,7 @@
<li>Edit tags on audio files</li>
<li>Automatically retrieve tags from MusicBrainz</li>
<li>Album cover art from Last.fm, Musicbrainz, Discogs, Musixmatch, Deezer, Tidal, Qobuz and Spotify</li>
<li>Song lyrics from Genius, Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com and elyrics.net</li>
<li>Song lyrics from Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com and elyrics.net</li>
<li>Audio analyzer and equalizer</li>
<li>Transfer music to mass-storage USB players, MTP compatible devices and iPod Nano/Classic</li>
<li>Scrobbler with support for Last.fm, Libre.fm and ListenBrainz</li>
@@ -51,6 +51,10 @@
</screenshots>
<update_contact>eclipseo@fedoraproject.org</update_contact>
<releases>
<release version="1.2.10" date="2025-04-18"/>
<release version="1.2.9" date="2025-04-08"/>
<release version="1.2.8" date="2025-04-05"/>
<release version="1.2.7" date="2025-01-31"/>
<release version="1.2.6" date="2025-01-17"/>
<release version="1.2.5" date="2025-01-17"/>
<release version="1.2.4" date="2025-01-10"/>

View File

@@ -13,7 +13,7 @@ TryExec=strawberry
Icon=strawberry
Terminal=false
Categories=AudioVideo;Player;Qt;Audio;
Keywords=Audio;Player;
Keywords=Audio;Player;Clementine;
StartupNotify=false
MimeType=x-content/audio-player;application/ogg;application/x-ogg;application/x-ogm-audio;audio/flac;audio/ogg;audio/vorbis;audio/aac;audio/mp4;audio/mpeg;audio/mpegurl;audio/vnd.rn-realaudio;audio/x-flac;audio/x-oggflac;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-speex;audio/x-wav;audio/x-wavpack;audio/x-ape;audio/x-mp3;audio/x-mpeg;audio/x-mpegurl;audio/x-ms-wma;audio/x-musepack;audio/x-pn-realaudio;audio/x-scpls;video/x-ms-asf;x-scheme-handler/tidal;
StartupWMClass=strawberry

View File

@@ -29,7 +29,7 @@ Features:
.br
- Album cover art from Last.fm, Musicbrainz, Discogs, Musixmatch, Deezer, Tidal, Qobuz and Spotify
.br
- Song lyrics from Lyrics.com, Genius, Musixmatch, ChartLyrics, lyrics.ovh and lololyrics.com
- Song lyrics from Musixmatch, ChartLyrics, lyrics.ovh and lololyrics.com
.br
- Support for multiple backends
.br

View File

@@ -63,8 +63,14 @@ BuildRequires: pkgconfig(libcdio)
BuildRequires: pkgconfig(libebur128)
BuildRequires: pkgconfig(libgpod-1.0)
BuildRequires: pkgconfig(libmtp)
BuildRequires: pkgconfig(libsparsehash)
BuildRequires: cmake(GTest)
BuildRequires: pkgconfig(gmock)
BuildRequires: cmake(RapidJSON)
%if 0%{?fedora} || (0%{?suse_version} && 0%{?suse_version} > 1600) || "%{?_vendor}" == "openmandriva"
BuildRequires: cmake(KDSingleApplication-qt6)
%endif
%if 0%{?suse_version}
Requires: qt6-sql-sqlite
@@ -87,7 +93,7 @@ Features:
- Edit tags on audio files
- Automatically retrieve tags from MusicBrainz
- Album cover art from Last.fm, Musicbrainz, Discogs, Musixmatch, Deezer, Tidal, Qobuz and Spotify
- Song lyrics from Genius, Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com and elyrics.net
- Song lyrics from Musixmatch, ChartLyrics, lyrics.ovh, lololyrics.com, songlyrics.com, azlyrics.com and elyrics.net
- Support for multiple backends
- Audio analyzer
- Audio equalizer
@@ -96,7 +102,7 @@ Features:
- Streaming support for Subsonic-compatible servers
- Unofficial streaming support for Tidal and Qobuz
%if 0%{?suse_version}
%if 0%{?suse_version} && 0%{?suse_version} <= 1600
%debug_package
%endif

View File

@@ -259,7 +259,7 @@ Section "Strawberry" Strawberry
File "libssl-3-x64.dll"
!endif
File "libFLAC-12.dll"
File "libFLAC-14.dll"
File "libbrotlicommon.dll"
File "libbrotlidec.dll"
File "libbrotlienc.dll"
@@ -324,7 +324,7 @@ Section "Strawberry" Strawberry
File "libqtsparkle-qt6.dll"
File "libsoup-3.0-0.dll"
File "libspeex-1.dll"
File "libsqlite3-0.dll"
File "libsqlite3.dll"
File "libssp-0.dll"
File "libstdc++-6.dll"
File "libtag.dll"
@@ -458,11 +458,11 @@ Section "Strawberry" Strawberry
; Common files
File "icudt76.dll"
File "icudt77.dll"
File "libfftw3-3.dll"
!ifdef msvc && debug
File "icuin76d.dll"
File "icuuc76d.dll"
File "icuin77d.dll"
File "icuuc77d.dll"
File "libxml2d.dll"
File "Qt6Concurrentd.dll"
File "Qt6Cored.dll"
@@ -471,8 +471,8 @@ Section "Strawberry" Strawberry
File "Qt6Sqld.dll"
File "Qt6Widgetsd.dll"
!else
File "icuin76.dll"
File "icuuc76.dll"
File "icuin77.dll"
File "icuuc77.dll"
File "libxml2.dll"
File "Qt6Concurrent.dll"
File "Qt6Core.dll"
@@ -596,6 +596,7 @@ Section "Gstreamer plugins" gstreamer-plugins
File "/oname=libgstapp.dll" "gstreamer-plugins\libgstapp.dll"
File "/oname=libgstasf.dll" "gstreamer-plugins\libgstasf.dll"
File "/oname=libgstasfmux.dll" "gstreamer-plugins\libgstasfmux.dll"
File "/oname=libgstasio.dll" "gstreamer-plugins\libgstasio.dll"
File "/oname=libgstaudioconvert.dll" "gstreamer-plugins\libgstaudioconvert.dll"
File "/oname=libgstaudiofx.dll" "gstreamer-plugins\libgstaudiofx.dll"
File "/oname=libgstaudioparsers.dll" "gstreamer-plugins\libgstaudioparsers.dll"
@@ -783,7 +784,7 @@ Section "Uninstall"
Delete "$INSTDIR\libssl-3-x64.dll"
!endif
Delete "$INSTDIR\libFLAC-12.dll"
Delete "$INSTDIR\libFLAC-14.dll"
Delete "$INSTDIR\libbrotlicommon.dll"
Delete "$INSTDIR\libbrotlidec.dll"
Delete "$INSTDIR\libbrotlienc.dll"
@@ -848,7 +849,7 @@ Section "Uninstall"
Delete "$INSTDIR\libqtsparkle-qt6.dll"
Delete "$INSTDIR\libsoup-3.0-0.dll"
Delete "$INSTDIR\libspeex-1.dll"
Delete "$INSTDIR\libsqlite3-0.dll"
Delete "$INSTDIR\libsqlite3.dll"
Delete "$INSTDIR\libssp-0.dll"
Delete "$INSTDIR\libstdc++-6.dll"
Delete "$INSTDIR\libtag.dll"
@@ -981,11 +982,11 @@ Section "Uninstall"
; Common files
Delete "$INSTDIR\icudt76.dll"
Delete "$INSTDIR\icudt77.dll"
Delete "$INSTDIR\libfftw3-3.dll"
!ifdef msvc && debug
Delete "$INSTDIR\icuin76d.dll"
Delete "$INSTDIR\icuuc76d.dll"
Delete "$INSTDIR\icuin77d.dll"
Delete "$INSTDIR\icuuc77d.dll"
Delete "$INSTDIR\libxml2d.dll"
Delete "$INSTDIR\Qt6Concurrentd.dll"
Delete "$INSTDIR\Qt6Cored.dll"
@@ -994,8 +995,8 @@ Section "Uninstall"
Delete "$INSTDIR\Qt6Sqld.dll"
Delete "$INSTDIR\Qt6Widgetsd.dll"
!else
Delete "$INSTDIR\icuin76.dll"
Delete "$INSTDIR\icuuc76.dll"
Delete "$INSTDIR\icuin77.dll"
Delete "$INSTDIR\icuuc77.dll"
Delete "$INSTDIR\libxml2.dll"
Delete "$INSTDIR\Qt6Concurrent.dll"
Delete "$INSTDIR\Qt6Core.dll"
@@ -1052,6 +1053,7 @@ Section "Uninstall"
Delete "$INSTDIR\gstreamer-plugins\libgstapp.dll"
Delete "$INSTDIR\gstreamer-plugins\libgstasf.dll"
Delete "$INSTDIR\gstreamer-plugins\libgstasfmux.dll"
Delete "$INSTDIR\gstreamer-plugins\libgstasio.dll"
Delete "$INSTDIR\gstreamer-plugins\libgstaudioconvert.dll"
Delete "$INSTDIR\gstreamer-plugins\libgstaudiofx.dll"
Delete "$INSTDIR\gstreamer-plugins\libgstaudioparsers.dll"

View File

@@ -100,7 +100,7 @@ void AnalyzerBase::transform(Scope &scope) {
fht_->logSpectrum(scope.data(), aux.data());
fht_->scale(scope.data(), 1.0F / 20);
scope.resize(fht_->size() / 2); // second half of values are rubbish
scope.resize(static_cast<size_t>(fht_->size() / 2)); // second half of values are rubbish
}
@@ -112,7 +112,7 @@ void AnalyzerBase::paintEvent(QPaintEvent *e) {
switch (engine_->state()) {
case EngineBase::State::Playing:{
const EngineBase::Scope &thescope = engine_->scope(timeout_);
int i = 0;
size_t i = 0;
// convert to mono here - our built in analyzers need mono, but the engines provide interleaved pcm
for (uint x = 0; static_cast<int>(x) < fht_->size(); ++x) {
@@ -124,7 +124,7 @@ void AnalyzerBase::paintEvent(QPaintEvent *e) {
transform(lastscope_);
analyze(p, lastscope_, new_frame_);
lastscope_.resize(fht_->size());
lastscope_.resize(static_cast<size_t>(fht_->size()));
break;
}
@@ -153,7 +153,7 @@ int AnalyzerBase::resizeExponent(int exp) {
if (exp != fht_->sizeExp()) {
delete fht_;
fht_ = new FHT(exp);
fht_ = new FHT(static_cast<uint>(exp));
}
return exp;

View File

@@ -85,7 +85,7 @@ void BlockAnalyzer::resizeEvent(QResizeEvent *e) {
// this is the y-offset for drawing from the top of the widget
y_ = (height() - (rows_ * (kHeight + 1)) + 2) / 2;
scope_.resize(columns_);
scope_.resize(static_cast<size_t>(columns_));
if (rows_ != oldRows) {
barpixmap_ = QPixmap(kWidth, rows_ * (kHeight + 1));
@@ -165,9 +165,9 @@ void BlockAnalyzer::analyze(QPainter &p, const Scope &s, const bool new_frame) {
// Paint the background
canvas_painter.drawPixmap(0, 0, background_);
for (int x = 0, y = 0; x < static_cast<int>(scope_.size()); ++x) {
for (qint64 x = 0, y = 0; x < static_cast<qint64>(scope_.size()); ++x) {
// determine y
for (y = 0; scope_[x] < yscale_.at(y); ++y);
for (y = 0; scope_[static_cast<quint64>(x)] < yscale_.at(y); ++y);
// This is opposite to what you'd think, higher than y means the bar is lower than y (physically)
if (static_cast<double>(y) > store_.at(x)) {
@@ -175,13 +175,13 @@ void BlockAnalyzer::analyze(QPainter &p, const Scope &s, const bool new_frame) {
y = static_cast<int>(store_.value(x));
}
else {
store_[x] = y;
store_[x] = static_cast<double>(y);
}
// If y is lower than fade_pos_, then the bar has exceeded the height of the fadeout
// if the fadeout is quite faded now, then display the new one
if (y <= fade_pos_.at(x) /*|| fade_intensity_[x] < kFadeSize / 3*/) {
fade_pos_[x] = y;
fade_pos_[x] = static_cast<int>(y);
fade_intensity_[x] = kFadeSize;
}
@@ -189,13 +189,13 @@ void BlockAnalyzer::analyze(QPainter &p, const Scope &s, const bool new_frame) {
--fade_intensity_[x];
const int offset = fade_intensity_.value(x);
const int y2 = y_ + (fade_pos_.value(x) * (kHeight + 1));
canvas_painter.drawPixmap(x * (kWidth + 1), y2, fade_bars_[offset], 0, 0, kWidth, height() - y2);
canvas_painter.drawPixmap(static_cast<int>(x) * (kWidth + 1), y2, fade_bars_[offset], 0, 0, kWidth, height() - y2);
}
if (fade_intensity_.at(x) == 0) fade_pos_[x] = rows_;
// REMEMBER: y is a number from 0 to rows_, 0 means all blocks are glowing, rows_ means none are
canvas_painter.drawPixmap(x * (kWidth + 1), y * (kHeight + 1) + y_, *bar(), 0, y * (kHeight + 1), bar()->width(), bar()->height());
canvas_painter.drawPixmap(static_cast<int>(x) * (kWidth + 1), static_cast<int>(y) * (kHeight + 1) + y_, *bar(), 0, static_cast<int>(y) * (kHeight + 1), bar()->width(), bar()->height());
}
for (int x = 0; x < store_.size(); ++x) {

View File

@@ -76,7 +76,7 @@ void BoomAnalyzer::resizeEvent(QResizeEvent *e) {
const double h = 1.2 / HEIGHT;
bands_ = qMin(static_cast<int>(static_cast<double>(width() + 1) / (kColumnWidth + 1)) + 1, kMaxBandCount);
scope_.resize(bands_);
scope_.resize(static_cast<size_t>(bands_));
F_ = static_cast<double>(HEIGHT) / (log10(256) * 1.1 /*<- max. amplitude*/);
@@ -112,17 +112,19 @@ void BoomAnalyzer::analyze(QPainter &p, const Scope &scope, const bool new_frame
return;
}
const uint MAX_HEIGHT = height() - 1;
const uint MAX_HEIGHT = static_cast<uint>(height() - 1);
QPainter canvas_painter(&canvas_);
canvas_.fill(palette().color(QPalette::Window));
interpolate(scope, scope_);
for (int i = 0, x = 0, y = 0; i < bands_; ++i, x += kColumnWidth + 1) {
int x = 0;
int y = 0;
for (size_t i = 0; i < static_cast<size_t>(bands_); ++i, x += kColumnWidth + 1) {
double h = log10(scope_[i] * 256.0) * F_;
if (h > MAX_HEIGHT) h = MAX_HEIGHT;
h = std::min(h, static_cast<double>(MAX_HEIGHT));
if (h > bar_height_[i]) {
bar_height_[i] = h;
@@ -138,7 +140,7 @@ void BoomAnalyzer::analyze(QPainter &p, const Scope &scope, const bool new_frame
else {
if (bar_height_[i] > 0.0) {
bar_height_[i] -= K_barHeight_; // 1.4
if (bar_height_[i] < 0.0) bar_height_[i] = 0.0;
bar_height_[i] = std::max(0.0, bar_height_[i]);
}
peak_handling:
@@ -147,8 +149,8 @@ void BoomAnalyzer::analyze(QPainter &p, const Scope &scope, const bool new_frame
peak_height_[i] -= peak_speed_[i];
peak_speed_[i] *= F_peakSpeed_; // 1.12
if (peak_height_[i] < bar_height_[i]) peak_height_[i] = bar_height_[i];
if (peak_height_[i] < 0.0) peak_height_[i] = 0.0;
peak_height_[i] = std::max(bar_height_[i], bar_height_[i]);
peak_height_[i] = std::max(0.0, peak_height_[i]);
}
}

View File

@@ -129,7 +129,7 @@ void RainbowAnalyzer::analyze(QPainter &p, const Scope &s, const bool new_frame)
// of band pass filters for this, so bands can leak into neighbouring bands,
// but for now it's a series of separate square filters.
const int samples_per_band = scope_size / kRainbowBands;
int sample = 0;
size_t sample = 0;
for (int band = 0; band < kRainbowBands; ++band) {
float accumulator = 0.0;
for (int i = 0; i < samples_per_band; ++i) {

View File

@@ -85,10 +85,10 @@ void SonogramAnalyzer::transform(Scope &scope) {
fht_->power2(scope.data());
fht_->scale(scope.data(), 1.0 / 256);
scope.resize(fht_->size() / 2);
scope.resize(static_cast<size_t>(fht_->size() / 2));
}
void SonogramAnalyzer::demo(QPainter &p) {
analyze(p, Scope(fht_->size(), 0), new_frame_);
analyze(p, Scope(static_cast<size_t>(fht_->size()), 0), new_frame_);
}

View File

@@ -43,7 +43,7 @@ void TurbineAnalyzer::analyze(QPainter &p, const Scope &scope, const bool new_fr
return;
}
const uint hd2 = height() / 2;
const uint hd2 = static_cast<uint>(height() / 2);
const uint kMaxHeight = hd2 - 1;
QPainter canvas_painter(&canvas_);
@@ -67,7 +67,7 @@ void TurbineAnalyzer::analyze(QPainter &p, const Scope &scope, const bool new_fr
else {
if (bar_height_[i] > 0.0) {
bar_height_[i] -= K_barHeight_; // 1.4
if (bar_height_[i] < 0.0) bar_height_[i] = 0.0;
bar_height_[i] = std::max(0.0, bar_height_[i]);
}
peak_handling:

View File

@@ -54,13 +54,13 @@ void WaveRubberAnalyzer::analyze(QPainter &p, const Scope &s, const bool new_fra
const float *amplitude_data = s.data();
const int mid_y = height() / 4;
const int num_samples = static_cast<int>(s.size());
const size_t num_samples = static_cast<size_t>(s.size());
const float x_scale = static_cast<float>(width()) / static_cast<float>(num_samples);
float prev_y = static_cast<float>(mid_y);
// Draw the waveform
for (int i = 0; i < num_samples; ++i) {
for (size_t i = 0; i < num_samples; ++i) {
// Normalize amplitude to 0-1 range
const float color_factor = amplitude_data[i] / 2.0F + 0.5F;
@@ -88,5 +88,5 @@ void WaveRubberAnalyzer::transform(Scope &scope) {
}
void WaveRubberAnalyzer::demo(QPainter &p) {
analyze(p, Scope(fht_->size(), 0), new_frame_);
analyze(p, Scope(static_cast<size_t>(fht_->size()), 0), new_frame_);
}

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, 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
@@ -522,7 +522,7 @@ void CollectionBackend::SongPathChanged(const Song &song, const QFileInfo &new_f
updated_song.set_url(QUrl::fromLocalFile(QDir::cleanPath(new_file.filePath())));
updated_song.set_basefilename(new_file.fileName());
updated_song.InitArtManual();
if (updated_song.is_collection_song() && new_collection_directory_id) {
if (updated_song.is_linked_collection_song() && new_collection_directory_id) {
updated_song.set_directory_id(new_collection_directory_id.value());
}
@@ -853,6 +853,10 @@ void CollectionBackend::UpdateMTimesOnly(const SongList &songs) {
}
void CollectionBackend::DeleteSongsAsync(const SongList &songs) {
QMetaObject::invokeMethod(this, "DeleteSongs", Qt::QueuedConnection, Q_ARG(SongList, songs));
}
void CollectionBackend::DeleteSongs(const SongList &songs) {
QMutexLocker l(db_->Mutex());
@@ -879,6 +883,24 @@ void CollectionBackend::DeleteSongs(const SongList &songs) {
}
void CollectionBackend::DeleteSongsByUrlsAsync(const QList<QUrl> &urls) {
QMetaObject::invokeMethod(this, "DeleteSongsByUrl", Qt::QueuedConnection, Q_ARG(QList<QUrl>, urls));
}
void CollectionBackend::DeleteSongsByUrls(const QList<QUrl> &urls) {
SongList songs;
songs.reserve(urls.count());
for (const QUrl &url : urls) {
songs << GetSongsByUrl(url);
}
if (!songs.isEmpty()) {
DeleteSongs(songs);
}
}
void CollectionBackend::MarkSongsUnavailable(const SongList &songs, const bool unavailable) {
QMutexLocker l(db_->Mutex());

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2010, David Sansome <me@davidsansome.com>
* Copyright 2018-2024, 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
@@ -234,6 +234,9 @@ class CollectionBackend : public CollectionBackendInterface {
void UpdateSongRatingAsync(const int id, const float rating, const bool save_tags = false);
void UpdateSongsRatingAsync(const QList<int> &ids, const float rating, const bool save_tags = false);
void DeleteSongsAsync(const SongList &songs);
void DeleteSongsByUrlsAsync(const QList<QUrl> &url);
public Q_SLOTS:
void Exit();
void GetAllSongs(const int id);
@@ -247,6 +250,7 @@ class CollectionBackend : public CollectionBackendInterface {
void UpdateSongsBySongID(const SongMap &new_songs);
void UpdateMTimesOnly(const SongList &songs);
void DeleteSongs(const SongList &songs);
void DeleteSongsByUrls(const QList<QUrl> &url);
void MarkSongsUnavailable(const SongList &songs, const bool unavailable = true);
void AddOrUpdateSubdirs(const CollectionSubdirectoryList &subdirs);
void CompilationsNeedUpdating();

View File

@@ -199,8 +199,8 @@ void CollectionLibrary::SyncPlaycountAndRatingToFiles() {
task_manager_->SetTaskBlocksCollectionScans(task_id);
const SongList songs = backend_->GetAllSongs();
const qint64 nb_songs = songs.size();
int i = 0;
const quint64 nb_songs = static_cast<quint64>(songs.size());
quint64 i = 0;
for (const Song &song : songs) {
(void)tagreader_client_->SaveSongPlaycountBlocking(song.url().toLocalFile(), song.playcount());
(void)tagreader_client_->SaveSongRatingBlocking(song.url().toLocalFile(), song.rating());

View File

@@ -397,13 +397,13 @@ QMimeData *CollectionModel::mimeData(const QModelIndexList &indexes) const {
GetChildSongs(IndexToItem(idx), songs, song_ids, urls);
}
SongMimeData *data = new SongMimeData;
data->setUrls(urls);
data->backend = backend_;
data->songs = songs;
data->name_for_new_playlist_ = Song::GetNameForNewPlaylist(data->songs);
SongMimeData *song_mime_data = new SongMimeData;
song_mime_data->setUrls(urls);
song_mime_data->backend = backend_;
song_mime_data->songs = songs;
song_mime_data->name_for_new_playlist_ = Song::GetNameForNewPlaylist(songs);
return data;
return song_mime_data;
}

View File

@@ -155,7 +155,7 @@ class CollectionModel : public SimpleTreeModel<CollectionItem> {
int total_artist_count() const { return total_artist_count_; }
int total_album_count() const { return total_album_count_; }
quint64 icon_disk_cache_size() { return icon_disk_cache_->cacheSize(); }
quint64 icon_disk_cache_size() { return static_cast<quint64>(icon_disk_cache_->cacheSize()); }
const CollectionModel::Grouping GetGroupBy() const { return options_current_.group_by; }
void SetGroupBy(const CollectionModel::Grouping g, const std::optional<bool> separate_albums_by_grouping = std::optional<bool>());

View File

@@ -1,8 +1,6 @@
/*
* 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
@@ -30,41 +28,52 @@
class SqlRow;
CollectionPlaylistItem::CollectionPlaylistItem() : PlaylistItem(Song::Source::Collection) {
song_.set_source(Song::Source::Collection);
CollectionPlaylistItem::CollectionPlaylistItem(const Song::Source source) : PlaylistItem(source) {
song_.set_source(source);
}
CollectionPlaylistItem::CollectionPlaylistItem(const Song &song) : PlaylistItem(Song::Source::Collection), song_(song) {
song_.set_source(Song::Source::Collection);
}
CollectionPlaylistItem::CollectionPlaylistItem(const Song &song) : PlaylistItem(song.source()), song_(song) {}
QUrl CollectionPlaylistItem::Url() const { return song_.url(); }
void CollectionPlaylistItem::Reload() {
const TagReaderResult result = TagReaderClient::Instance()->ReadFileBlocking(song_.url().toLocalFile(), &song_);
if (!result.success()) {
qLog(Error) << "Could not reload file" << song_.url() << result.error_string();
return;
}
UpdateTemporaryMetadata(song_);
}
bool CollectionPlaylistItem::InitFromQuery(const SqlRow &query) {
// Rows from the songs tables come first
song_.InitFromQuery(query, true);
song_.set_source(Song::Source::Collection);
int col = 0;
switch (source_) {
case Song::Source::Collection:
col = 0;
break;
default:
col = static_cast<int>(Song::kRowIdColumns.count());
break;
}
song_.InitFromQuery(query, true, col);
return song_.is_valid();
}
QVariant CollectionPlaylistItem::DatabaseValue(DatabaseColumn column) const {
void CollectionPlaylistItem::Reload() {
switch (column) {
case Column_CollectionId: return song_.id();
default: return PlaylistItem::DatabaseValue(column);
if (song_.url().isLocalFile()) {
const TagReaderResult result = TagReaderClient::Instance()->ReadFileBlocking(song_.url().toLocalFile(), &song_);
if (!result.success()) {
qLog(Error) << "Could not reload file" << song_.url() << result.error_string();
return;
}
UpdateTemporaryMetadata(song_);
}
}
QVariant CollectionPlaylistItem::DatabaseValue(const DatabaseColumn database_column) const {
switch (database_column) {
case DatabaseColumn::CollectionId:
return song_.id();
default:
return PlaylistItem::DatabaseValue(database_column);
}
}

View File

@@ -1,8 +1,6 @@
/*
* 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
@@ -34,9 +32,11 @@ class SqlRow;
class CollectionPlaylistItem : public PlaylistItem {
public:
explicit CollectionPlaylistItem();
explicit CollectionPlaylistItem(const Song::Source source);
explicit CollectionPlaylistItem(const Song &song);
QUrl Url() const override;
bool InitFromQuery(const SqlRow &query) override;
void Reload() override;
@@ -44,15 +44,13 @@ class CollectionPlaylistItem : public PlaylistItem {
Song OriginalMetadata() const override { return song_; }
void SetMetadata(const Song &song) override { song_ = song; }
QUrl Url() const override;
bool IsLocalCollectionItem() const override { return true; }
void SetArtManual(const QUrl &cover_url) override;
bool IsLocalCollectionItem() const override { return song_.source() == Song::Source::Collection; }
protected:
QVariant DatabaseValue(DatabaseColumn column) const override;
Song DatabaseSongMetadata() const override { return Song(Song::Source::Collection); }
QVariant DatabaseValue(const DatabaseColumn database_column) const override;
Song DatabaseSongMetadata() const override { return Song(source_); }
protected:
Song song_;

View File

@@ -65,7 +65,7 @@
#include "collectionitem.h"
#include "collectionitemdelegate.h"
#include "collectionview.h"
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
# include "device/devicemanager.h"
# include "device/devicestatefiltermodel.h"
#endif
@@ -95,7 +95,7 @@ CollectionView::CollectionView(QWidget *parent)
action_open_in_new_playlist_(nullptr),
action_organize_(nullptr),
action_search_for_this_(nullptr),
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
action_copy_to_device_(nullptr),
#endif
action_edit_track_(nullptr),
@@ -417,7 +417,7 @@ void CollectionView::contextMenuEvent(QContextMenuEvent *e) {
context_menu_->addSeparator();
action_organize_ = context_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Organize files..."), this, &CollectionView::Organize);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
action_copy_to_device_ = context_menu_->addAction(IconLoader::Load(u"device"_s), tr("Copy to device..."), this, &CollectionView::CopyToDevice);
#endif
action_delete_files_ = context_menu_->addAction(IconLoader::Load(u"edit-delete"_s), tr("Delete from disk..."), this, &CollectionView::Delete);
@@ -439,7 +439,7 @@ void CollectionView::contextMenuEvent(QContextMenuEvent *e) {
context_menu_->addMenu(filter_widget_->menu());
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
action_copy_to_device_->setDisabled(device_manager_->connected_devices_model()->rowCount() == 0);
QObject::connect(device_manager_->connected_devices_model(), &DeviceStateFilterModel::IsEmptyChanged, action_copy_to_device_, &QAction::setDisabled);
#endif
@@ -481,7 +481,7 @@ void CollectionView::contextMenuEvent(QContextMenuEvent *e) {
action_rescan_songs_->setEnabled(regular_editable > 0);
action_organize_->setVisible(regular_elements == regular_editable);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
action_copy_to_device_->setVisible(regular_elements == regular_editable);
#endif
@@ -492,7 +492,7 @@ void CollectionView::contextMenuEvent(QContextMenuEvent *e) {
// only when all selected items are editable
action_organize_->setEnabled(regular_elements == regular_editable);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
action_copy_to_device_->setEnabled(regular_elements == regular_editable);
#endif
@@ -759,7 +759,7 @@ void CollectionView::RescanSongs() {
void CollectionView::CopyToDevice() {
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
if (!organize_dialog_) {
organize_dialog_ = make_unique<OrganizeDialog>(task_manager_, tagreader_client_, nullptr, this);
}

View File

@@ -176,7 +176,7 @@ class CollectionView : public AutoExpandingTreeView {
QAction *action_organize_;
QAction *action_search_for_this_;
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
QAction *action_copy_to_device_;
#endif
QAction *action_edit_track_;

View File

@@ -858,7 +858,7 @@ void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file,
QHash<quint64, Song> sections_map;
for (const Song &song : old_cue_songs) {
sections_map.insert(song.beginning_nanosec(), song);
sections_map.insert(static_cast<quint64>(song.beginning_nanosec()), song);
}
// Load new CUE songs
@@ -879,8 +879,8 @@ void CollectionWatcher::UpdateCueAssociatedSongs(const QString &file,
PerformEBUR128Analysis(new_cue_song);
new_cue_song.set_fingerprint(fingerprint);
if (sections_map.contains(new_cue_song.beginning_nanosec())) { // Changed section
const Song matching_cue_song = sections_map[new_cue_song.beginning_nanosec()];
if (sections_map.contains(static_cast<quint64>(new_cue_song.beginning_nanosec()))) { // Changed section
const Song matching_cue_song = sections_map[static_cast<quint64>(new_cue_song.beginning_nanosec())];
new_cue_song.set_id(matching_cue_song.id());
new_cue_song.set_art_automatic(art_automatic);
new_cue_song.MergeUserSetData(matching_cue_song, true, true);
@@ -1082,7 +1082,7 @@ quint64 CollectionWatcher::GetMtimeForCue(const QString &cue_path) {
const QDateTime cue_last_modified = fileinfo.lastModified();
return cue_last_modified.isValid() ? cue_last_modified.toSecsSinceEpoch() : 0;
return cue_last_modified.isValid() ? static_cast<quint64>(cue_last_modified.toSecsSinceEpoch()) : 0;
}

View File

@@ -27,10 +27,12 @@
#cmakedefine HAVE_GLOBALSHORTCUTS
#cmakedefine HAVE_X11_GLOBALSHORTCUTS
#cmakedefine HAVE_KGLOBALACCEL_GLOBALSHORTCUTS
#cmakedefine HAVE_STREAMTAGREADER
#cmakedefine HAVE_SUBSONIC
#cmakedefine HAVE_TIDAL
#cmakedefine HAVE_SPOTIFY
#cmakedefine HAVE_QOBUZ
#cmakedefine HAVE_DISCORD_RPC
#cmakedefine HAVE_TAGLIB_DSFFILE
#cmakedefine HAVE_TAGLIB_DSDIFFFILE
@@ -41,8 +43,8 @@
#cmakedefine INSTALL_TRANSLATIONS
#define TRANSLATIONS_DIR "${CMAKE_INSTALL_PREFIX}/share/strawberry/translations"
#cmakedefine HAVE_QPA_QPLATFORMNATIVEINTERFACE
#cmakedefine HAVE_QX11APPLICATION
#cmakedefine HAVE_QPA_QPLATFORMNATIVEINTERFACE_H
#cmakedefine ENABLE_WIN32_CONSOLE

View File

@@ -26,10 +26,14 @@ namespace BackendSettings {
constexpr char kSettingsGroup[] = "Backend";
constexpr char kEngine[] = "Engine";
constexpr char kOutput[] = "Output";
constexpr char kDevice[] = "Device";
constexpr char kEngine[] = "engine";
constexpr char kEngineU[] = "Engine";
constexpr char kOutput[] = "output";
constexpr char kOutputU[] = "Output";
constexpr char kDevice[] = "device";
constexpr char kDeviceU[] = "Device";
constexpr char kALSAPlugin[] = "alsaplugin";
constexpr char kPlaybin3[] = "playbin3";
constexpr char kExclusiveMode[] = "exclusive_mode";
constexpr char kVolumeControl[] = "volume_control";
constexpr char kChannelsEnabled[] = "channels_enabled";

View File

@@ -65,4 +65,12 @@ constexpr QRgb kPresetRed = qRgb(202, 22, 16);
} // namespace
namespace DiscordRPCSettings {
constexpr char kSettingsGroup[] = "DiscordRPC";
constexpr char kEnabled[] = "enabled";
} // namespace
#endif // NOTIFICATIONSSETTINGS_H

View File

@@ -3,7 +3,7 @@
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2018-2024, 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
@@ -36,6 +36,7 @@
#include <QMetaObject>
#include <QCoreApplication>
#include <QAbstractEventDispatcher>
#include <QTimer>
#include "core/logging.h"
@@ -63,7 +64,6 @@
#include "covermanager/opentidalcoverprovider.h"
#include "lyrics/lyricsproviders.h"
#include "lyrics/geniuslyricsprovider.h"
#include "lyrics/ovhlyricsprovider.h"
#include "lyrics/lololyricsprovider.h"
#include "lyrics/musixmatchlyricsprovider.h"
@@ -172,7 +172,6 @@ class ApplicationImpl {
lyrics_providers_([app]() {
LyricsProviders *lyrics_providers = new LyricsProviders(app);
// Initialize the repository of lyrics providers.
lyrics_providers->AddProvider(new GeniusLyricsProvider(lyrics_providers->network()));
lyrics_providers->AddProvider(new OVHLyricsProvider(lyrics_providers->network()));
lyrics_providers->AddProvider(new LoloLyricsProvider(lyrics_providers->network()));
lyrics_providers->AddProvider(new MusixmatchLyricsProvider(lyrics_providers->network()));
@@ -208,7 +207,7 @@ class ApplicationImpl {
scrobbler->AddService(make_shared<LibreFMScrobbler>(scrobbler->settings(), app->network()));
scrobbler->AddService(make_shared<ListenBrainzScrobbler>(scrobbler->settings(), app->network()));
#ifdef HAVE_SUBSONIC
scrobbler->AddService(make_shared<SubsonicScrobbler>(scrobbler->settings(), app->streaming_services()->Service<SubsonicService>(), app));
scrobbler->AddService(make_shared<SubsonicScrobbler>(scrobbler->settings(), app->network(), app->streaming_services()->Service<SubsonicService>(), app));
#endif
return scrobbler;
}),

View File

@@ -598,7 +598,7 @@ void Database::BackupFile(const QString &filename) {
do {
ret = sqlite3_backup_step(backup, 16);
const int page_count = sqlite3_backup_pagecount(backup);
task_manager_->SetTaskProgress(task_id, page_count - sqlite3_backup_remaining(backup), page_count);
task_manager_->SetTaskProgress(task_id, static_cast<quint64>(page_count - sqlite3_backup_remaining(backup)), static_cast<quint64>(page_count));
}
while (ret == SQLITE_OK);

View File

@@ -92,7 +92,7 @@ void DeleteFiles::ProcessSomeFiles() {
// None left?
if (progress_ >= songs_.count()) {
task_manager_->SetTaskProgress(task_id_, progress_, songs_.count());
task_manager_->SetTaskProgress(task_id_, static_cast<quint64>(progress_), static_cast<quint64>(songs_.count()));
QString error_text;
storage_->FinishCopy(songs_with_errors_.isEmpty(), error_text);
@@ -114,7 +114,7 @@ void DeleteFiles::ProcessSomeFiles() {
const qint64 n = qMin(static_cast<qint64>(songs_.count()), static_cast<qint64>(progress_ + kBatchSize));
for (; progress_ < n; ++progress_) {
task_manager_->SetTaskProgress(task_id_, progress_, songs_.count());
task_manager_->SetTaskProgress(task_id_, static_cast<quint64>(progress_), static_cast<quint64>(songs_.count()));
const Song song = songs_.value(progress_);

View File

@@ -113,7 +113,11 @@ bool FilesystemMusicStorage::DeleteFromStorage(const DeleteJob &job) {
QString path = job.metadata_.url().toLocalFile();
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);
}

View File

@@ -0,0 +1,181 @@
/*
* 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/>.
*
*/
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QSslError>
#include <QJsonDocument>
#include <QJsonObject>
#include "core/logging.h"
#include "networkaccessmanager.h"
#include "httpbaserequest.h"
using namespace Qt::Literals::StringLiterals;
HttpBaseRequest::HttpBaseRequest(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: QObject(parent),
network_(network) {}
HttpBaseRequest::~HttpBaseRequest() {
if (!replies_.isEmpty()) {
qLog(Debug) << "Aborting" << replies_.count() << "network replies";
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
}
QNetworkReply *HttpBaseRequest::CreateGetRequest(const QUrl &url, const bool fake_user_agent_header) {
return CreateGetRequest(url, QUrlQuery(), fake_user_agent_header);
}
QNetworkReply *HttpBaseRequest::CreateGetRequest(const QUrl &url, const ParamList &params, const bool fake_user_agent_header) {
QUrlQuery url_query;
if (!params.isEmpty()) {
ParamList sorted_params = params;
std::sort(sorted_params.begin(), sorted_params.end());
for (const Param &param : sorted_params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
}
return CreateGetRequest(url, url_query, fake_user_agent_header);
}
QNetworkReply *HttpBaseRequest::CreateGetRequest(const QUrl &url, const QUrlQuery &url_query, const bool fake_user_agent_header) {
QUrl request_url(url);
if (!url_query.isEmpty()) {
request_url.setQuery(url_query);
}
QNetworkRequest network_request(request_url);
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
if (use_authorization_header() && authenticated()) {
network_request.setRawHeader("Authorization", authorization_header());
}
if (fake_user_agent_header) {
network_request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0"_s);
}
QNetworkReply *reply = network_->get(network_request);
QObject::connect(reply, &QNetworkReply::sslErrors, this, &HttpBaseRequest::HandleSSLErrors);
replies_ << reply;
//qLog(Debug) << service_name() << "Sending get request" << request_url;
return reply;
}
QNetworkReply *HttpBaseRequest::CreatePostRequest(const QUrl &url, const QByteArray &content_type_header, const QByteArray &data) {
QNetworkRequest network_request(url);
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
network_request.setHeader(QNetworkRequest::ContentTypeHeader, content_type_header);
if (use_authorization_header() && authenticated()) {
network_request.setRawHeader("Authorization", authorization_header());
}
QNetworkReply *reply = network_->post(network_request, data);
QObject::connect(reply, &QNetworkReply::sslErrors, this, &HttpBaseRequest::HandleSSLErrors);
replies_ << reply;
//qLog(Debug) << service_name() << "Sending post request" << url << data;
return reply;
}
QNetworkReply *HttpBaseRequest::CreatePostRequest(const QUrl &url, const QUrlQuery &url_query) {
return CreatePostRequest(url, "application/x-www-form-urlencoded", url_query.toString(QUrl::FullyEncoded).toUtf8());
}
QNetworkReply *HttpBaseRequest::CreatePostRequest(const QUrl &url, const ParamList &params) {
QUrlQuery url_query;
for (const Param &param : std::as_const(params)) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
return CreatePostRequest(url, url_query);
}
QNetworkReply *HttpBaseRequest::CreatePostRequest(const QUrl &url, const QJsonDocument &json_document) {
return CreatePostRequest(url, "application/json; charset=utf-8", json_document.toJson());
}
QNetworkReply *HttpBaseRequest::CreatePostRequest(const QUrl &url, const QJsonObject &json_object) {
return CreatePostRequest(url, QJsonDocument(json_object));
}
void HttpBaseRequest::HandleSSLErrors(const QList<QSslError> &ssl_errors) {
for (const QSslError &ssl_error : ssl_errors) {
Error(ssl_error.errorString());
}
}
HttpBaseRequest::ReplyDataResult HttpBaseRequest::GetReplyData(QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
const int http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (http_status_code < 200 || http_status_code > 207) {
return ReplyDataResult(ErrorCode::HttpError, QStringLiteral("Received HTTP code %1").arg(http_status_code));
}
}
return reply->readAll();
}
void HttpBaseRequest::Error(const QString &error_message, const QVariant &debug_output) {
qLog(Error) << service_name() << error_message;
if (debug_output.isValid()) {
qLog(Debug) << debug_output;
}
}

110
src/core/httpbaserequest.h Normal file
View File

@@ -0,0 +1,110 @@
/*
* 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 HTTPBASEREQUEST_H
#define HTTPBASEREQUEST_H
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkReply>
#include <QSslError>
#include <QJsonDocument>
#include <QJsonObject>
#include "includes/shared_ptr.h"
class NetworkAccessManager;
class HttpBaseRequest : public QObject {
Q_OBJECT
public:
explicit HttpBaseRequest(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~HttpBaseRequest() override;
using Param = QPair<QString, QString>;
using ParamList = QList<Param>;
enum class ErrorCode {
Success,
NetworkError,
HttpError,
APIError,
ParseError,
};
class HttpBaseRequestResult {
public:
HttpBaseRequestResult(const ErrorCode _error_code, const QString &_error_message = QString())
: error_code(_error_code),
network_error(QNetworkReply::NetworkError::UnknownNetworkError),
http_status_code(200),
api_error(-1),
error_message(_error_message) {}
ErrorCode error_code;
QNetworkReply::NetworkError network_error;
int http_status_code;
int api_error;
QString error_message;
bool success() const { return error_code == ErrorCode::Success; }
};
class ReplyDataResult : public HttpBaseRequestResult {
public:
ReplyDataResult(const ErrorCode _error_code, const QString &_error_message = QString()) : HttpBaseRequestResult(_error_code, _error_message) {}
ReplyDataResult(const QByteArray &_data) : HttpBaseRequestResult(ErrorCode::Success), data(_data) {}
QByteArray data;
};
static ReplyDataResult GetReplyData(QNetworkReply *reply);
protected:
virtual QString service_name() const = 0;
virtual bool authentication_required() const = 0;
virtual bool authenticated() const = 0;
virtual bool use_authorization_header() const = 0;
virtual QByteArray authorization_header() const = 0;
virtual QNetworkReply *CreateGetRequest(const QUrl &url, const bool fake_user_agent_header);
virtual QNetworkReply *CreateGetRequest(const QUrl &url, const ParamList &params = ParamList(), const bool fake_user_agent_header = false);
virtual QNetworkReply *CreateGetRequest(const QUrl &url, const QUrlQuery &url_query, const bool fake_user_agent_header = false);
virtual QNetworkReply *CreatePostRequest(const QUrl &url, const QByteArray &content_type_header, const QByteArray &data);
virtual QNetworkReply *CreatePostRequest(const QUrl &url, const QUrlQuery &url_query);
virtual QNetworkReply *CreatePostRequest(const QUrl &url, const ParamList &params);
virtual QNetworkReply *CreatePostRequest(const QUrl &url, const QJsonDocument &json_document);
virtual QNetworkReply *CreatePostRequest(const QUrl &url, const QJsonObject &json_object);
virtual void Error(const QString &error_message, const QVariant &debug_output = QVariant());
public Q_SLOTS:
void HandleSSLErrors(const QList<QSslError> &ssl_errors);
Q_SIGNALS:
void ShowErrorDialog(const QString &error);
protected:
const SharedPtr<NetworkAccessManager> network_;
QList<QNetworkReply*> replies_;
};
#endif // HTTPBASEREQUEST_H

View File

@@ -43,7 +43,7 @@ bool IconLoader::custom_icons_ = false;
void IconLoader::Init() {
#if !defined(Q_OS_MACOS) && !defined(Q_OS_WIN)
#if !defined(Q_OS_MACOS) && !defined(Q_OS_WIN32)
Settings s;
s.beginGroup(AppearanceSettings::kSettingsGroup);
system_icons_ = s.value("system_icons", false).toBool();

View File

@@ -0,0 +1,103 @@
/*
* 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/>.
*
*/
#include <QByteArray>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonParseError>
#include "networkaccessmanager.h"
#include "jsonbaserequest.h"
using namespace Qt::Literals::StringLiterals;
JsonBaseRequest::JsonBaseRequest(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: HttpBaseRequest(network, parent) {}
JsonBaseRequest::JsonObjectResult JsonBaseRequest::GetJsonObject(const QByteArray &data) {
if (data.isEmpty()) {
return JsonObjectResult(ErrorCode::ParseError, "Empty data from server"_L1);
}
QJsonParseError json_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
return JsonObjectResult(ErrorCode::ParseError, json_error.errorString());
}
if (json_document.isEmpty()) {
return JsonObjectResult(ErrorCode::ParseError, "Received empty Json document."_L1);
}
if (!json_document.isObject()) {
return JsonObjectResult(ErrorCode::ParseError, "Json document is not an object."_L1);
}
const QJsonObject json_object = json_document.object();
if (json_object.isEmpty()) {
return JsonObjectResult(ErrorCode::ParseError, "Received empty Json object."_L1);
}
return json_object;
}
JsonBaseRequest::JsonValueResult JsonBaseRequest::GetJsonValue(const QJsonObject &json_object, const QString &name) {
if (!json_object.contains(name)) {
return JsonValueResult(ErrorCode::ParseError, QStringLiteral("Json object is missing value %1.").arg(name));
}
return json_object[name];
}
JsonBaseRequest::JsonObjectResult JsonBaseRequest::GetJsonObject(const QJsonObject &json_object, const QString &name) {
if (!json_object.contains(name)) {
return JsonValueResult(ErrorCode::ParseError, QStringLiteral("Json object is missing object %1.").arg(name));
}
const QJsonValue json_value = json_object[name];
if (!json_value.isObject()) {
return JsonValueResult(ErrorCode::ParseError, QStringLiteral("Json value %1 is not a object.").arg(name));
}
return json_value.toObject();
}
JsonBaseRequest::JsonArrayResult JsonBaseRequest::GetJsonArray(const QJsonObject &json_object, const QString &name) {
const JsonValueResult json_value_result = GetJsonValue(json_object, name);
if (!json_value_result.success()) {
return JsonArrayResult(ErrorCode::ParseError, json_value_result.error_message);
}
if (!json_value_result.json_value.isArray()) {
return JsonArrayResult(ErrorCode::ParseError, QStringLiteral("Json object value %1 is not a array.").arg(name));
}
return json_value_result.json_value.toArray();
}

View File

@@ -0,0 +1,68 @@
/*
* 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 JSONBASEREQUEST_H
#define JSONBASEREQUEST_H
#include <QByteArray>
#include <QJsonObject>
#include <QJsonArray>
#include "includes/shared_ptr.h"
#include "httpbaserequest.h"
class NetworkAccessManager;
class JsonBaseRequest : public HttpBaseRequest {
Q_OBJECT
public:
explicit JsonBaseRequest(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
class JsonValueResult : public ReplyDataResult {
public:
JsonValueResult(const ReplyDataResult &reply_data_result) : ReplyDataResult(reply_data_result.error_code, reply_data_result.error_message) {}
JsonValueResult(const ErrorCode _error_code, const QString &_error_message = QString()) : ReplyDataResult(_error_code, _error_message) {}
JsonValueResult(const QJsonValue &_json_value) : ReplyDataResult(ErrorCode::Success), json_value(_json_value) {}
QJsonValue json_value;
};
class JsonObjectResult : public ReplyDataResult {
public:
JsonObjectResult(const ReplyDataResult &reply_data_result) : ReplyDataResult(reply_data_result.error_code, reply_data_result.error_message) {}
JsonObjectResult(const ErrorCode _error_code, const QString &_error_message = QString()) : ReplyDataResult(_error_code, _error_message) {}
JsonObjectResult(const QJsonObject &_json_object) : ReplyDataResult(ErrorCode::Success), json_object(_json_object) {}
QJsonObject json_object;
};
class JsonArrayResult : public ReplyDataResult {
public:
JsonArrayResult(const ReplyDataResult &reply_data_result) : ReplyDataResult(reply_data_result.error_code, reply_data_result.error_message) {}
JsonArrayResult(const ErrorCode _error_code, const QString &_error_message = QString()) : ReplyDataResult(_error_code, _error_message) {}
JsonArrayResult(const QJsonArray &_json_array) : ReplyDataResult(ErrorCode::Success), json_array(_json_array) {}
QJsonArray json_array;
};
static JsonObjectResult GetJsonObject(const QByteArray &data);
static JsonValueResult GetJsonValue(const QJsonObject &json_object, const QString &name);
static JsonObjectResult GetJsonObject(const QJsonObject &json_object, const QString &name);
static JsonArrayResult GetJsonArray(const QJsonObject &json_object, const QString &name);
};
#endif // JSONBASEREQUEST_H

View File

@@ -1,8 +1,7 @@
/*
* This file was part of Clementine.
* Strawberry Music Player
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.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
@@ -44,7 +43,8 @@ using namespace Qt::Literals::StringLiterals;
LocalRedirectServer::LocalRedirectServer(QObject *parent)
: QTcpServer(parent),
port_(0),
socket_(nullptr) {}
socket_(nullptr),
success_(false) {}
LocalRedirectServer::~LocalRedirectServer() {
if (isListening()) close();
@@ -52,7 +52,8 @@ LocalRedirectServer::~LocalRedirectServer() {
bool LocalRedirectServer::Listen() {
if (!listen(QHostAddress::LocalHost, port_)) {
if (!listen(QHostAddress::LocalHost, static_cast<quint64>(port_))) {
success_ = false;
error_ = errorString();
return false;
}
@@ -61,6 +62,7 @@ bool LocalRedirectServer::Listen() {
url_.setHost(u"localhost"_s);
url_.setPort(serverPort());
url_.setPath(u"/"_s);
port_ = serverPort();
QObject::connect(this, &QTcpServer::newConnection, this, &LocalRedirectServer::NewConnection);
return true;
@@ -77,7 +79,7 @@ void LocalRedirectServer::NewConnection() {
void LocalRedirectServer::incomingConnection(qintptr socket_descriptor) {
if (socket_) {
if (socket_ != nullptr) {
if (socket_->state() == QAbstractSocket::ConnectedState) socket_->close();
socket_->deleteLater();
socket_ = nullptr;
@@ -88,6 +90,7 @@ void LocalRedirectServer::incomingConnection(qintptr socket_descriptor) {
if (!tcp_socket->setSocketDescriptor(socket_descriptor)) {
delete tcp_socket;
close();
success_ = false;
error_ = "Unable to set socket descriptor"_L1;
Q_EMIT Finished();
return;
@@ -115,6 +118,10 @@ void LocalRedirectServer::ReadyRead() {
socket_->deleteLater();
socket_ = nullptr;
request_url_ = ParseUrlFromRequest(buffer_);
success_ = request_url_.isValid();
if (!request_url_.isValid()) {
error_ = "Invalid request URL"_L1;
}
close();
Q_EMIT Finished();
}
@@ -169,9 +176,9 @@ QUrl LocalRedirectServer::ParseUrlFromRequest(const QByteArray &request) const {
const QByteArrayList lines = request.split('\r');
const QByteArray &request_line = lines[0];
QByteArray path = request_line.split(' ')[1];
QUrl base_url = url_;
QUrl request_url(base_url.toString() + QString::fromLatin1(path.mid(1)), QUrl::StrictMode);
const QByteArray path = request_line.split(' ')[1];
const QUrl base_url = url_;
const QUrl request_url(base_url.toString() + QString::fromLatin1(path.mid(1)), QUrl::StrictMode);
return request_url;

View File

@@ -1,8 +1,7 @@
/*
* This file was part of Clementine.
* Strawberry Music Player
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.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
@@ -39,12 +38,16 @@ class LocalRedirectServer : public QTcpServer {
explicit LocalRedirectServer(QObject *parent = nullptr);
~LocalRedirectServer() override;
void set_port(const int port) { port_ = port; }
bool Listen();
const QUrl &url() const { return url_; }
const QUrl &request_url() const { return request_url_; }
bool success() const { return success_; }
const QString &error() const { return error_; }
int port() const { return port_; }
void set_port(const int port) { port_ = port; }
bool Listen();
Q_SIGNALS:
void Finished();
@@ -66,6 +69,7 @@ class LocalRedirectServer : public QTcpServer {
QUrl request_url_;
QAbstractSocket *socket_;
QByteArray buffer_;
bool success_;
QString error_;
};

View File

@@ -156,7 +156,7 @@
#include "lyrics/lyricsproviders.h"
#include "device/devicemanager.h"
#include "device/devicestatefiltermodel.h"
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
# include "device/deviceview.h"
# include "device/deviceviewcontainer.h"
#endif
@@ -208,7 +208,7 @@
#include "organize/organizeerrordialog.h"
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
# include "core/windows7thumbbar.h"
#endif
@@ -228,6 +228,10 @@
# include <qtsparkle-qt6/Updater>
#endif // HAVE_QTSPARKLE
#ifdef HAVE_DISCORD_RPC
#include "discord/richpresence.h"
#endif
using std::make_unique;
using std::make_shared;
using namespace std::chrono_literals;
@@ -275,15 +279,24 @@ constexpr char QTSPARKLE_URL[] = "https://www.strawberrymusicplayer.org/sparkle-
} // namespace
#endif // HAVE_QTSPARKLE
MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OSDBase *osd, const CommandlineOptions &options, QWidget *parent)
MainWindow::MainWindow(Application *app,
SharedPtr<SystemTrayIcon> tray_icon, OSDBase *osd,
#ifdef HAVE_DISCORD_RPC
discord::RichPresence *discord_rich_presence,
#endif
const CommandlineOptions &options,
QWidget *parent)
: QMainWindow(parent),
ui_(new Ui_MainWindow),
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
thumbbar_(new Windows7ThumbBar(this)),
#endif
app_(app),
tray_icon_(tray_icon),
osd_(osd),
#ifdef HAVE_DISCORD_RPC
discord_rich_presence_(discord_rich_presence),
#endif
console_([app, this]() {
Console *console = new Console(app->database());
QObject::connect(console, &Console::Error, this, &MainWindow::ShowErrorDialog);
@@ -297,7 +310,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
context_view_(new ContextView(this)),
collection_view_(new CollectionViewContainer(this)),
file_view_(new FileView(this)),
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
device_view_(new DeviceViewContainer(this)),
#endif
playlist_list_(new PlaylistListContainer(this)),
@@ -362,7 +375,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
playlist_move_to_collection_(nullptr),
playlist_open_in_browser_(nullptr),
playlist_organize_(nullptr),
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
playlist_copy_to_device_(nullptr),
#endif
playlist_delete_(nullptr),
@@ -388,6 +401,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
was_minimized_(false),
exit_(false),
exit_count_(0),
playlists_loaded_(false),
delete_files_(false) {
qLog(Debug) << "Starting";
@@ -416,7 +430,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
ui_->tabs->AddTab(smartplaylists_view_, u"smartplaylists"_s, IconLoader::Load(u"view-media-playlist"_s, true, 0, 32), tr("Smart playlists"));
ui_->tabs->AddTab(file_view_, u"files"_s, IconLoader::Load(u"document-open"_s, true, 0, 32), tr("Files"));
ui_->tabs->AddTab(radio_view_, u"radios"_s, IconLoader::Load(u"radio"_s, true, 0, 32), tr("Radios"));
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
ui_->tabs->AddTab(device_view_, u"devices"_s, IconLoader::Load(u"device"_s, true, 0, 32), tr("Devices"));
#endif
#ifdef HAVE_SUBSONIC
@@ -466,7 +480,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
collection_view_->view()->setModel(app_->collection()->model()->filter());
collection_view_->view()->Init(app->task_manager(), app->tagreader_client(), app->network(), app->albumcover_loader(), app->current_albumcover_loader(), app->cover_providers(), app->lyrics_providers(), app->collection(), app->device_manager(), app->streaming_services());
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
device_view_->view()->Init(app->task_manager(), app->tagreader_client(), app->device_manager(), app->collection_model()->directory_model());
#endif
playlist_list_->Init(app_->task_manager(), app->tagreader_client(), app_->playlist_manager(), app_->playlist_backend(), app_->device_manager());
@@ -540,7 +554,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(file_view_, &FileView::CopyToCollection, this, &MainWindow::CopyFilesToCollection);
QObject::connect(file_view_, &FileView::MoveToCollection, this, &MainWindow::MoveFilesToCollection);
QObject::connect(file_view_, &FileView::EditTags, this, &MainWindow::EditFileTags);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
QObject::connect(file_view_, &FileView::CopyToDevice, this, &MainWindow::CopyFilesToDevice);
#endif
file_view_->SetTaskManager(app_->task_manager());
@@ -654,7 +668,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(&*app_->player(), &Player::Playing, playlist_list_, &PlaylistListContainer::ActivePlaying);
QObject::connect(&*app_->player(), &Player::Stopped, playlist_list_, &PlaylistListContainer::ActiveStopped);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::AllPlaylistsLoaded, &*app->player(), &Player::PlaylistsLoaded);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::AllPlaylistsLoaded, this, &MainWindow::PlaylistsLoaded);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, this, &MainWindow::SongChanged);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->player(), &Player::CurrentMetadataChanged);
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::EditingFinished, this, &MainWindow::PlaylistEditFinished);
@@ -704,7 +718,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(album_cover_choice_controller_->search_cover_auto_action(), &QAction::triggered, this, &MainWindow::SearchCoverAutomatically);
QObject::connect(album_cover_choice_controller_->search_cover_auto_action(), &QAction::toggled, this, &MainWindow::ToggleSearchCoverAuto);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
// Devices connections
QObject::connect(device_view_->view(), &DeviceView::AddToPlaylistSignal, this, &MainWindow::AddToPlaylist);
#endif
@@ -810,7 +824,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
playlist_organize_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Organize files..."), this, &MainWindow::PlaylistMoveToCollection);
playlist_copy_to_collection_ = playlist_menu_->addAction(IconLoader::Load(u"edit-copy"_s), tr("Copy to collection..."), this, &MainWindow::PlaylistCopyToCollection);
playlist_move_to_collection_ = playlist_menu_->addAction(IconLoader::Load(u"go-jump"_s), tr("Move to collection..."), this, &MainWindow::PlaylistMoveToCollection);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
playlist_copy_to_device_ = playlist_menu_->addAction(IconLoader::Load(u"device"_s), tr("Copy to device..."), this, &MainWindow::PlaylistCopyToDevice);
#endif
playlist_delete_ = playlist_menu_->addAction(IconLoader::Load(u"edit-delete"_s), tr("Delete from disk..."), this, &MainWindow::PlaylistDelete);
@@ -854,7 +868,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(&*tray_icon_, &SystemTrayIcon::ChangeVolume, this, &MainWindow::VolumeWheelEvent);
// Windows 7 thumbbar buttons
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
thumbbar_->SetActions(QList<QAction*>() << ui_->action_previous_track << ui_->action_play_pause << ui_->action_stop << ui_->action_next_track << nullptr << ui_->action_love);
#endif
@@ -966,7 +980,7 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
QObject::connect(&*app_->lastfm_import(), &LastFMImport::UpdateLastPlayed, &*app_->collection_backend(), &CollectionBackend::UpdateLastPlayed);
QObject::connect(&*app_->lastfm_import(), &LastFMImport::UpdatePlayCount, &*app_->collection_backend(), &CollectionBackend::UpdatePlayCount);
#if !defined(HAVE_AUDIOCD) || defined(Q_OS_WIN)
#if !defined(HAVE_AUDIOCD) || defined(Q_OS_WIN32)
ui_->action_open_cd->setEnabled(false);
ui_->action_open_cd->setVisible(false);
#endif
@@ -1317,6 +1331,9 @@ void MainWindow::ReloadAllSettings() {
qobuz_view_->ReloadSettings();
qobuz_view_->search_view()->ReloadSettings();
#endif
#ifdef HAVE_DISCORD_RPC
discord_rich_presence_->ReloadSettings();
#endif
}
@@ -1392,6 +1409,19 @@ void MainWindow::ExitFinished() {
}
void MainWindow::PlaylistsLoaded() {
playlists_loaded_ = true;
if (options_.has_value()) {
CommandlineOptionsReceived(options_.value());
options_.reset();
}
app_->player()->PlaylistsLoaded();
}
void MainWindow::MediaStopped() {
setWindowTitle(u"Strawberry Music Player"_s);
@@ -1516,7 +1546,7 @@ void MainWindow::SongChanged(const Song &song) {
SendNowPlaying();
const bool enable_change_art = song.is_collection_song() && !song.effective_albumartist().isEmpty() && !song.album().isEmpty();
const bool enable_change_art = song.is_local_collection_song() && !song.effective_albumartist().isEmpty() && !song.album().isEmpty();
album_cover_choice_controller_->show_cover_action()->setEnabled(song.has_valid_art() && !song.art_unset());
album_cover_choice_controller_->cover_to_file_action()->setEnabled(song.has_valid_art() && !song.art_unset());
album_cover_choice_controller_->cover_from_file_action()->setEnabled(enable_change_art);
@@ -2002,7 +2032,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
playlist_show_in_collection_->setVisible(false);
playlist_copy_to_collection_->setVisible(false);
playlist_move_to_collection_->setVisible(false);
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
playlist_copy_to_device_->setVisible(false);
#endif
playlist_organize_->setVisible(false);
@@ -2077,7 +2107,7 @@ void MainWindow::PlaylistRightClick(const QPoint global_pos, const QModelIndex &
playlist_move_to_collection_->setVisible(local_songs > 0);
}
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
playlist_copy_to_device_->setVisible(local_songs > 0);
#endif
@@ -2447,6 +2477,11 @@ void MainWindow::CommandlineOptionsReceived(const QByteArray &string_options) {
void MainWindow::CommandlineOptionsReceived(const CommandlineOptions &options) {
if (!playlists_loaded_) {
options_ = options;
return;
}
switch (options.player_action()) {
case CommandlineOptions::PlayerAction::Play:
if (options.urls().empty()) {
@@ -2566,10 +2601,10 @@ void MainWindow::CommandlineOptionsReceived(const CommandlineOptions &options) {
}
if (options.seek_to() != -1) {
app_->player()->SeekTo(options.seek_to());
app_->player()->SeekTo(static_cast<quint64>(options.seek_to()));
}
else if (options.seek_by() != 0) {
app_->player()->SeekTo(app_->player()->engine()->position_nanosec() / kNsecPerSec + options.seek_by());
app_->player()->SeekTo(static_cast<quint64>(app_->player()->engine()->position_nanosec() / kNsecPerSec + options.seek_by()));
}
if (options.play_track_at() != -1) app_->player()->PlayAt(options.play_track_at(), false, 0, EngineBase::TrackChangeType::Manual, Playlist::AutoScroll::Maybe, true);
@@ -2716,7 +2751,7 @@ void MainWindow::MoveFilesToCollection(const QList<QUrl> &urls) {
void MainWindow::CopyFilesToDevice(const QList<QUrl> &urls) {
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
organize_dialog_->SetDestinationModel(app_->device_manager()->connected_devices_model(), true);
organize_dialog_->SetCopy(true);
if (organize_dialog_->SetUrls(urls)) {
@@ -2856,7 +2891,7 @@ void MainWindow::PlaylistSkip() {
void MainWindow::PlaylistCopyToDevice() {
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
SongList songs;
@@ -2946,7 +2981,7 @@ void MainWindow::OpenSettingsDialog() {
}
void MainWindow::OpenSettingsDialogAtPage(SettingsDialog::Page page) {
void MainWindow::OpenSettingsDialogAtPage(const SettingsDialog::Page page) {
settings_dialog_->OpenAtPage(page);
}
@@ -3032,7 +3067,7 @@ void MainWindow::Raise() {
}
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
bool MainWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr *result) {
if (exit_count_ == 0 && message) {
@@ -3041,7 +3076,7 @@ bool MainWindow::nativeEvent(const QByteArray &eventType, void *message, qintptr
}
return QMainWindow::nativeEvent(eventType, message, result);
}
#endif // Q_OS_WIN
#endif // Q_OS_WIN32
void MainWindow::AutoCompleteTags() {
@@ -3197,7 +3232,7 @@ void MainWindow::AlbumCoverLoaded(const Song &song, const AlbumCoverLoaderResult
Q_EMIT AlbumCoverReady(song, result.album_cover.image);
const bool enable_change_art = song.is_collection_song() && !song.effective_albumartist().isEmpty() && !song.album().isEmpty();
const bool enable_change_art = song.is_local_collection_song() && !song.effective_albumartist().isEmpty() && !song.album().isEmpty();
album_cover_choice_controller_->show_cover_action()->setEnabled(result.success && result.type != AlbumCoverLoaderResult::Type::Unset);
album_cover_choice_controller_->cover_to_file_action()->setEnabled(result.success && result.type != AlbumCoverLoaderResult::Type::Unset);
album_cover_choice_controller_->cover_from_file_action()->setEnabled(enable_change_art);

View File

@@ -24,6 +24,8 @@
#include "config.h"
#include <optional>
#include <QtGlobal>
#include <QObject>
#include <QWidget>
@@ -52,6 +54,7 @@
#include "core/platforminterface.h"
#include "core/song.h"
#include "core/settings.h"
#include "core/commandlineoptions.h"
#include "tagreader/tagreaderclient.h"
#include "osd/osdbase.h"
#include "playlist/playlist.h"
@@ -70,7 +73,7 @@ class CollectionViewContainer;
class CollectionFilter;
class AlbumCoverChoiceController;
class CommandlineOptions;
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
class DeviceViewContainer;
#endif
class EditTagDialog;
@@ -92,18 +95,31 @@ class Ui_MainWindow;
class StreamingSongsView;
class StreamingTabsView;
class SmartPlaylistsViewContainer;
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
class Windows7ThumbBar;
#endif
class AddStreamDialog;
class LastFMImportDialog;
class RadioViewContainer;
#ifdef HAVE_DISCORD_RPC
namespace discord {
class RichPresence;
}
#endif
class MainWindow : public QMainWindow, public PlatformInterface {
Q_OBJECT
public:
explicit MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OSDBase *osd, const CommandlineOptions &options, QWidget *parent = nullptr);
explicit MainWindow(Application *app,
SharedPtr<SystemTrayIcon> tray_icon,
OSDBase *osd,
#ifdef HAVE_DISCORD_RPC
discord::RichPresence *discord_rich_presence,
#endif
const CommandlineOptions &options,
QWidget *parent = nullptr);
~MainWindow() override;
void SetHiddenInTray(const bool hidden);
@@ -114,7 +130,7 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void hideEvent(QHideEvent *e) override;
void closeEvent(QCloseEvent *e) override;
void keyPressEvent(QKeyEvent *e) override;
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;
#endif
@@ -131,6 +147,8 @@ class MainWindow : public QMainWindow, public PlatformInterface {
void AuthorizationUrlReceived(const QUrl &url);
private Q_SLOTS:
void PlaylistsLoaded();
void FilePathChanged(const QString &path);
void MediaStopped();
@@ -224,7 +242,7 @@ class MainWindow : public QMainWindow, public PlatformInterface {
SettingsDialog *CreateSettingsDialog();
EditTagDialog *CreateEditTagDialog();
void OpenSettingsDialog();
void OpenSettingsDialogAtPage(SettingsDialog::Page page);
void OpenSettingsDialogAtPage(const SettingsDialog::Page page);
void TabSwitched();
void ToggleSidebar(const bool checked);
@@ -289,13 +307,16 @@ class MainWindow : public QMainWindow, public PlatformInterface {
private:
Ui_MainWindow *ui_;
#ifdef Q_OS_WIN
#ifdef Q_OS_WIN32
Windows7ThumbBar *thumbbar_;
#endif
Application *app_;
SharedPtr<SystemTrayIcon> tray_icon_;
OSDBase *osd_;
#ifdef HAVE_DISCORD_RPC
discord::RichPresence *discord_rich_presence_;
#endif
Lazy<About> about_dialog_;
Lazy<Console> console_;
Lazy<EditTagDialog> edit_tag_dialog_;
@@ -306,7 +327,7 @@ class MainWindow : public QMainWindow, public PlatformInterface {
ContextView *context_view_;
CollectionViewContainer *collection_view_;
FileView *file_view_;
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
DeviceViewContainer *device_view_;
#endif
PlaylistListContainer *playlist_list_;
@@ -359,7 +380,7 @@ class MainWindow : public QMainWindow, public PlatformInterface {
QAction *playlist_move_to_collection_;
QAction *playlist_open_in_browser_;
QAction *playlist_organize_;
#ifndef Q_OS_WIN
#ifndef Q_OS_WIN32
QAction *playlist_copy_to_device_;
#endif
QAction *playlist_delete_;
@@ -396,7 +417,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
AlbumCoverImageResult album_cover_;
bool exit_;
int exit_count_;
bool playlists_loaded_;
bool delete_files_;
std::optional<CommandlineOptions> options_;
};

View File

@@ -88,6 +88,4 @@ int MultiSortFilterProxy::Compare(const QVariant &left, const QVariant &right) c
}
}
return 0;
}

View File

@@ -86,7 +86,7 @@ void NetworkProxyFactory::ReloadSettings() {
mode_ = static_cast<Mode>(s.value("mode", static_cast<int>(Mode::System)).toInt());
type_ = QNetworkProxy::ProxyType(s.value("type", QNetworkProxy::HttpProxy).toInt());
hostname_ = s.value("hostname").toString();
port_ = s.value("port", 8080).toInt();
port_ = s.value("port", 8080).toULongLong();
use_authentication_ = s.value("use_authentication", false).toBool();
username_ = s.value("username").toString();
password_ = s.value("password").toString();

View File

@@ -57,7 +57,7 @@ class NetworkProxyFactory : public QNetworkProxyFactory {
Mode mode_;
QNetworkProxy::ProxyType type_;
QString hostname_;
int port_;
quint64 port_;
bool use_authentication_;
QString username_;
QString password_;

607
src/core/oauthenticator.cpp Normal file
View File

@@ -0,0 +1,607 @@
/*
* Strawberry Music Player
* Copyright 2022-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 <algorithm>
#include <QObject>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QDateTime>
#include <QTimer>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QCryptographicHash>
#include <QJsonDocument>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonParseError>
#include <QDesktopServices>
#include <QMessageBox>
#include "constants/timeconstants.h"
#include "utilities/randutils.h"
#include "logging.h"
#include "settings.h"
#include "networkaccessmanager.h"
#include "localredirectserver.h"
#include "oauthenticator.h"
using namespace Qt::Literals::StringLiterals;
using std::make_shared;
using namespace std::chrono_literals;
namespace {
constexpr char kTokenType[] = "token_type";
constexpr char kAccessToken[] = "access_token";
constexpr char kRefreshToken[] = "refresh_token";
constexpr char kExpiresIn[] = "expires_in";
constexpr char kLoginTime[] = "login_time";
constexpr char kUserId[] = "user_id";
constexpr char kCountryCode[] = "country_code";
constexpr int kMaxPortInc = 20;
} // namespace
OAuthenticator::OAuthenticator(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: QObject(parent),
network_(network),
timer_refresh_login_(new QTimer(this)),
type_(Type::Authorization_Code),
use_local_redirect_server_(true),
random_port_(true),
expires_in_(0LL),
login_time_(0LL),
user_id_(0) {
timer_refresh_login_->setSingleShot(true);
QObject::connect(timer_refresh_login_, &QTimer::timeout, this, &OAuthenticator::RerefreshAccessToken);
}
OAuthenticator::~OAuthenticator() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
void OAuthenticator::set_settings_group(const QString &settings_group) {
settings_group_ = settings_group;
}
void OAuthenticator::set_type(const Type type) {
type_ = type;
}
void OAuthenticator::set_authorize_url(const QUrl &authorize_url) {
authorize_url_ = authorize_url;
}
void OAuthenticator::set_redirect_url(const QUrl &redirect_url) {
redirect_url_ = redirect_url;
}
void OAuthenticator::set_access_token_url(const QUrl &access_token_url) {
access_token_url_ = access_token_url;
}
void OAuthenticator::set_client_id(const QString &client_id) {
client_id_ = client_id;
}
void OAuthenticator::set_client_secret(const QString &client_secret) {
client_secret_ = client_secret;
}
void OAuthenticator::set_scope(const QString &scope) {
scope_ = scope;
}
void OAuthenticator::set_use_local_redirect_server(const bool use_local_redirect_server) {
use_local_redirect_server_ = use_local_redirect_server;
}
void OAuthenticator::set_random_port(const bool random_port) {
random_port_ = random_port;
}
QByteArray OAuthenticator::authorization_header() const {
if (token_type_.isEmpty() || access_token_.isEmpty()) {
return QByteArray();
}
return token_type().toUtf8() + " " + access_token().toUtf8();
}
QString OAuthenticator::GrantType() const {
switch (type_) {
case Type::Authorization_Code:
return u"authorization_code"_s;
break;
case Type::Client_Credentials:
return u"client_credentials"_s;
break;
}
return QString();
}
void OAuthenticator::LoadSession() {
Settings s;
s.beginGroup(settings_group_);
token_type_ = s.value(kTokenType).toString();
access_token_ = s.value(kAccessToken).toString();
refresh_token_ = s.value(kRefreshToken).toString();
expires_in_ = s.value(kExpiresIn, 0LL).toLongLong();
login_time_ = s.value(kLoginTime, 0LL).toLongLong();
country_code_ = s.value(kCountryCode).toString();
user_id_ = s.value(kUserId).toULongLong();
s.endGroup();
StartRefreshLoginTimer();
}
void OAuthenticator::ClearSession() {
token_type_.clear();
access_token_.clear();
refresh_token_.clear();
expires_in_ = 0;
login_time_ = 0;
country_code_.clear();
user_id_ = 0;
Settings s;
s.beginGroup(settings_group_);
s.remove(kTokenType);
s.remove(kAccessToken);
s.remove(kRefreshToken);
s.remove(kExpiresIn);
s.remove(kLoginTime);
s.remove(kCountryCode);
s.remove(kUserId);
s.endGroup();
if (timer_refresh_login_->isActive()) {
timer_refresh_login_->stop();
}
}
void OAuthenticator::StartRefreshLoginTimer() {
if (login_time_ > 0 && !refresh_token_.isEmpty() && expires_in_ > 0) {
const qint64 time = std::max(1LL, expires_in_ - (QDateTime::currentSecsSinceEpoch() - login_time_));
qLog(Debug) << settings_group_ << "Refreshing login in" << time << "seconds";
timer_refresh_login_->setInterval(static_cast<int>(time * kMsecPerSec));
if (!timer_refresh_login_->isActive()) {
timer_refresh_login_->start();
}
}
}
void OAuthenticator::Authenticate() {
if (type_ == Type::Client_Credentials) {
RequestAccessToken();
return;
}
QUrl redirect_url(redirect_url_);
if (use_local_redirect_server_) {
local_redirect_server_.reset(new LocalRedirectServer(this));
bool success = false;
if (random_port_) {
success = local_redirect_server_->Listen();
}
else {
const int max_port = redirect_url.port() + kMaxPortInc;
for (int port = redirect_url.port(); port < max_port; ++port) {
local_redirect_server_->set_port(port);
if (local_redirect_server_->Listen()) {
success = true;
break;
}
}
}
if (!success) {
Q_EMIT AuthenticationFinished(false, local_redirect_server_->error());
local_redirect_server_.reset();
return;
}
QObject::connect(&*local_redirect_server_, &LocalRedirectServer::Finished, this, &OAuthenticator::RedirectArrived);
redirect_url.setPort(local_redirect_server_->port());
}
code_verifier_ = Utilities::CryptographicRandomString(44);
code_challenge_ = QString::fromLatin1(QCryptographicHash::hash(code_verifier_.toUtf8(), QCryptographicHash::Sha256).toBase64(QByteArray::Base64UrlEncoding));
if (code_challenge_.lastIndexOf(u'=') == code_challenge_.length() - 1) {
code_challenge_.chop(1);
}
ParamList params = ParamList() << Param(u"response_type"_s, u"code"_s)
<< Param(u"redirect_uri"_s, redirect_url.toString())
<< Param(u"state"_s, code_challenge_)
<< Param(u"code_challenge_method"_s, u"S256"_s)
<< Param(u"code_challenge"_s, code_challenge_);
if (!client_id_.isEmpty()) {
params << Param(u"client_id"_s, client_id_);
}
if (!scope_.isEmpty()) {
params << Param(u"scope"_s, scope_);
}
std::sort(params.begin(), params.end());
QUrlQuery url_query;
for (const Param &param : std::as_const(params)) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first, ";")), QString::fromLatin1(QUrl::toPercentEncoding(param.second, ";")));
}
QUrl url(authorize_url_);
url.setQuery(url_query);
const bool success = QDesktopServices::openUrl(url);
if (!success) {
QMessageBox messagebox(QMessageBox::Information, tr("Authentication"), tr("Please open this URL in your browser") + QStringLiteral(":<br /><a href=\"%1\">%1</a>").arg(url.toString()), QMessageBox::Ok);
messagebox.setTextFormat(Qt::RichText);
messagebox.exec();
}
}
void OAuthenticator::RedirectArrived() {
if (local_redirect_server_.isNull()) {
return;
}
if (local_redirect_server_->success()) {
QUrl redirect_url(redirect_url_);
redirect_url.setPort(local_redirect_server_->port());
AuthorizationUrlReceived(local_redirect_server_->request_url(), redirect_url);
}
else {
Q_EMIT AuthenticationFinished(false, local_redirect_server_->error());
}
local_redirect_server_.reset();
}
void OAuthenticator::ExternalAuthorizationUrlReceived(const QUrl &request_url) {
AuthorizationUrlReceived(request_url, redirect_url_);
}
void OAuthenticator::AuthorizationUrlReceived(const QUrl &request_url, const QUrl &redirect_url) {
if (!request_url.isValid()) {
Q_EMIT AuthenticationFinished(false, tr("Received invalid reply from web browser."));
return;
}
if (!request_url.hasQuery()) {
Q_EMIT AuthenticationFinished(false, tr("Redirect URL is missing query."));
return;
}
qLog(Debug) << settings_group_ << "Authorization URL Received" << request_url.toDisplayString();
QUrlQuery url_query(request_url);
if (url_query.hasQueryItem(u"error_description"_s)) {
Q_EMIT AuthenticationFinished(false, url_query.queryItemValue(u"error_description"_s, QUrl::FullyDecoded));
return;
}
if (url_query.hasQueryItem(u"error"_s)) {
Q_EMIT AuthenticationFinished(false, url_query.queryItemValue(u"error"_s));
return;
}
if (!url_query.hasQueryItem(u"code"_s)) {
Q_EMIT AuthenticationFinished(false, tr("Request URL is missing code!"));
return;
}
if (!url_query.hasQueryItem(u"state"_s)) {
Q_EMIT AuthenticationFinished(false, tr("Request URL is missing state!"));
return;
}
if (url_query.queryItemValue(u"state"_s) != code_challenge_) {
Q_EMIT AuthenticationFinished(false, tr("Request URL has wrong state %1 != %2").arg(url_query.queryItemValue(u"state"_s), code_challenge_));
return;
}
RequestAccessToken(url_query.queryItemValue(u"code"_s), redirect_url);
}
QNetworkReply *OAuthenticator::CreateAccessTokenRequest(const ParamList &params, const bool refresh_token) {
QUrlQuery url_query;
for (const Param &param : std::as_const(params)) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QNetworkRequest network_request(access_token_url_);
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
network_request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
if (type_ == Type::Client_Credentials && !client_id_.isEmpty() && !client_secret_.isEmpty()) {
const QString authorization_header = client_id_ + u':' + client_secret_;
network_request.setRawHeader("Authorization", "Basic " + authorization_header.toUtf8().toBase64());
}
QNetworkReply *reply = network_->post(network_request, url_query.toString(QUrl::FullyEncoded).toUtf8());
replies_ << reply;
QObject::connect(reply, &QNetworkReply::sslErrors, this, &OAuthenticator::HandleSSLErrors);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, refresh_token]() { AccessTokenRequestFinished(reply, refresh_token); });
return reply;
}
void OAuthenticator::RequestAccessToken(const QString &code, const QUrl &redirect_url) {
if (timer_refresh_login_->isActive()) {
timer_refresh_login_->stop();
}
ParamList params = ParamList() << Param(u"grant_type"_s, GrantType());
if (!code.isEmpty()) {
params << Param(u"code"_s, code);
}
if (!code_verifier_.isEmpty()) {
params << Param(u"code_verifier"_s, code_verifier_);
}
if (!code.isEmpty()) {
params << Param(u"redirect_uri"_s, redirect_url.toString());
}
if (!client_id_.isEmpty()) {
params << Param(u"client_id"_s, client_id_);
}
if (!client_secret_.isEmpty()) {
params << Param(u"client_secret"_s, client_secret_);
}
std::sort(params.begin(), params.end());
CreateAccessTokenRequest(params, false);
}
void OAuthenticator::RerefreshAccessToken() {
if (timer_refresh_login_->isActive()) {
timer_refresh_login_->stop();
}
if (client_id_.isEmpty() || refresh_token_.isEmpty()) {
return;
}
ParamList params = ParamList() << Param(u"grant_type"_s, u"refresh_token"_s)
<< Param(u"client_id"_s, client_id_)
<< Param(u"refresh_token"_s, refresh_token_);
if (!client_secret_.isEmpty()) {
params << Param(u"client_secret"_s, client_secret_);
}
CreateAccessTokenRequest(params, true);
}
void OAuthenticator::HandleSSLErrors(const QList<QSslError> &ssl_errors) {
for (const QSslError &ssl_error : ssl_errors) {
qLog(Debug) << settings_group_ << ssl_error.errorString();
}
}
void OAuthenticator::AccessTokenRequestFinished(QNetworkReply *reply, const bool refresh_token) {
if (!replies_.contains(reply)) return;
replies_.removeAll(reply);
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
const QString error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
Q_EMIT AuthenticationFinished(false, error_message);
}
if (reply->error() != QNetworkReply::NoError || reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_document.isEmpty() && json_document.isObject()) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object.contains("error_description"_L1)) {
const QString error = json_object["error"_L1].toString();
const QString error_description = json_object["error_description"_L1].toString();
Q_EMIT AuthenticationFinished(false, QStringLiteral("%1 (%2)").arg(error, error_description));
return;
}
qLog(Debug) << settings_group_ << "Unknown Json reply" << json_object;
}
}
if (reply->error() == QNetworkReply::NoError) {
Q_EMIT AuthenticationFinished(false, QStringLiteral("Received HTTP status code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
}
else {
Q_EMIT AuthenticationFinished(false, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
return;
}
const QByteArray data = reply->readAll();
QJsonParseError json_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Q_EMIT AuthenticationFinished(false, QStringLiteral("Failed to parse Json data in authentication reply: %1").arg(json_error.errorString()));
return;
}
if (json_document.isEmpty()) {
Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has empty Json document."_s);
return;
}
if (!json_document.isObject()) {
Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has Json document that is not an object."_s);
return;
}
const QJsonObject json_object = json_document.object();
if (json_object.isEmpty()) {
Q_EMIT AuthenticationFinished(false, u"Authentication reply from server has empty Json object."_s);
return;
}
if (!json_object.contains("token_type"_L1)) {
Q_EMIT AuthenticationFinished(false, u"Authentication reply from server is missing token type."_s);
return;
}
if (!json_object.contains("access_token"_L1)) {
Q_EMIT AuthenticationFinished(false, u"Authentication reply from server is missing access token."_s);
return;
}
token_type_ = json_object["token_type"_L1].toString();
access_token_ = json_object["access_token"_L1].toString();
if (json_object.contains("refresh_token"_L1)) {
refresh_token_ = json_object["refresh_token"_L1].toString();
}
else if (!refresh_token) {
refresh_token_.clear();
}
if (json_object.contains("expires_in"_L1)) {
expires_in_ = json_object["expires_in"_L1].toInt();
}
else {
expires_in_ = 0;
}
login_time_ = QDateTime::currentSecsSinceEpoch();
country_code_.clear();
user_id_ = 0;
if (json_object.contains("user"_L1) && json_object["user"_L1].isObject()) {
const QJsonObject object_user = json_object["user"_L1].toObject();
if (object_user.contains("countryCode"_L1) && object_user.contains("userId"_L1)) {
country_code_ = object_user["countryCode"_L1].toString();
user_id_ = static_cast<quint64>(object_user["userId"_L1].toInt());
}
}
Settings s;
s.beginGroup(settings_group_);
s.setValue(kTokenType, token_type_);
s.setValue(kAccessToken, access_token_);
s.setValue(kLoginTime, login_time_);
if (refresh_token_.isEmpty()) {
s.remove(kRefreshToken);
}
else {
s.setValue(kRefreshToken, refresh_token_);
}
if (expires_in_ == 0) {
s.remove(kExpiresIn);
}
else {
s.setValue(kExpiresIn, expires_in_);
}
if (country_code_.isEmpty()) {
s.remove(kCountryCode);
}
else {
s.setValue(kCountryCode, country_code_);
}
if (user_id_ == 0) {
s.remove(kUserId);
}
else {
s.setValue(kUserId, user_id_);
}
s.endGroup();
StartRefreshLoginTimer();
qLog(Debug) << settings_group_ << "Authentication was successful, login expires in" << expires_in_;
Q_EMIT AuthenticationFinished(true);
}

127
src/core/oauthenticator.h Normal file
View File

@@ -0,0 +1,127 @@
/*
* Strawberry Music Player
* Copyright 2022-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 OAUTHENTICATOR_H
#define OAUTHENTICATOR_H
#include "config.h"
#include <QObject>
#include <QList>
#include <QString>
#include <QUrl>
#include <QScopedPointer>
#include <QSharedPointer>
#include <QSslError>
#include "includes/shared_ptr.h"
class QTimer;
class QNetworkReply;
class NetworkAccessManager;
class LocalRedirectServer;
class OAuthenticator : public QObject {
Q_OBJECT
public:
explicit OAuthenticator(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~OAuthenticator() override;
enum class Type {
Authorization_Code,
Client_Credentials
};
void set_settings_group(const QString &settings_group);
void set_type(const Type type);
void set_authorize_url(const QUrl &auth_url);
void set_redirect_url(const QUrl &redirect_url);
void set_access_token_url(const QUrl &access_token_url);
void set_client_id(const QString &client_id);
void set_client_secret(const QString &client_secret);
void set_scope(const QString &scope);
void set_use_local_redirect_server(const bool use_local_redirect_server);
void set_random_port(const bool random_port);
QString token_type() const { return token_type_; }
QString access_token() const { return access_token_; }
qint64 expires_in() const { return expires_in_; }
QString country_code() const { return country_code_; }
quint64 user_id() const { return user_id_; }
bool authenticated() const { return !token_type_.isEmpty() && !access_token_.isEmpty(); }
QByteArray authorization_header() const;
void Authenticate();
void ClearSession();
void LoadSession();
void ExternalAuthorizationUrlReceived(const QUrl &request_url);
private:
using Param = QPair<QString, QString>;
using ParamList = QList<Param>;
QString GrantType() const;
void StartRefreshLoginTimer();
QNetworkReply *CreateAccessTokenRequest(const ParamList &params, const bool refresh_token);
void RequestAccessToken(const QString &code = QString(), const QUrl &redirect_url = QUrl());
void RerefreshAccessToken();
void AuthorizationUrlReceived(const QUrl &request_url, const QUrl &redirect_url);
Q_SIGNALS:
void Error(const QString &error);
void AuthenticationFinished(const bool success, const QString &error = QString());
private Q_SLOTS:
void RedirectArrived();
void HandleSSLErrors(const QList<QSslError> &ssl_errors);
void AccessTokenRequestFinished(QNetworkReply *reply, const bool refresh_token);
private:
const SharedPtr<NetworkAccessManager> network_;
QScopedPointer<LocalRedirectServer, QScopedPointerDeleteLater> local_redirect_server_;
QTimer *timer_refresh_login_;
QString settings_group_;
Type type_;
QUrl authorize_url_;
QUrl redirect_url_;
QUrl access_token_url_;
QString client_id_;
QString client_secret_;
QString scope_;
bool use_local_redirect_server_;
bool random_port_;
QString code_verifier_;
QString code_challenge_;
QString token_type_;
QString access_token_;
QString refresh_token_;
qint64 expires_in_;
qint64 login_time_;
QString country_code_;
quint64 user_id_;
QList<QNetworkReply*> replies_;
};
#endif // OAUTHENTICATOR_H

View File

@@ -173,7 +173,7 @@ void Player::LoadVolume() {
Settings s;
s.beginGroup(kSettingsGroup);
const uint volume = s.value(kVolume, 100).toInt();
const uint volume = s.value(kVolume, 100).toUInt();
s.endGroup();
SetVolume(volume);
@@ -240,7 +240,7 @@ void Player::ResumePlayback() {
s.beginGroup(kSettingsGroup);
const EngineBase::State playback_state = static_cast<EngineBase::State>(s.value(kPlaybackState, static_cast<int>(EngineBase::State::Empty)).toInt());
const int playback_playlist = s.value(kPlaybackPlaylist, -1).toInt();
const int playback_position = s.value(kPlaybackPosition, 0).toInt();
const quint64 playback_position = s.value(kPlaybackPosition, 0).toULongLong();
s.endGroup();
if (playback_playlist == playlist_manager_->current()->id()) {
@@ -372,7 +372,7 @@ void Player::HandleLoadResult(const UrlHandler::LoadResult &result) {
if (is_current) {
qLog(Debug) << "Playing song" << current_item->Metadata().title() << result.stream_url_ << "position" << play_offset_nanosec_;
engine_->Play(result.media_url_, result.stream_url_, pause_, stream_change_type_, song.has_cue(), song.beginning_nanosec(), song.end_nanosec(), play_offset_nanosec_, song.ebur128_integrated_loudness_lufs());
engine_->Play(result.media_url_, result.stream_url_, pause_, stream_change_type_, song.has_cue(), static_cast<quint64>(song.beginning_nanosec()), song.end_nanosec(), play_offset_nanosec_, song.ebur128_integrated_loudness_lufs());
current_item_ = current_item;
play_offset_nanosec_ = 0;
}
@@ -528,7 +528,7 @@ void Player::PlayPause(const quint64 offset_nanosec, const Playlist::AutoScroll
}
else {
pause_time_ = QDateTime::currentDateTime();
play_offset_nanosec_ = engine_->position_nanosec();
play_offset_nanosec_ = static_cast<quint64>(engine_->position_nanosec());
engine_->Pause();
}
break;
@@ -556,10 +556,10 @@ void Player::UnPause() {
if (current_item_ && pause_time_.isValid()) {
const Song &song = current_item_->Metadata();
if (url_handlers_->CanHandle(song.url()) && song.stream_url_can_expire()) {
const quint64 time = QDateTime::currentSecsSinceEpoch() - pause_time_.toSecsSinceEpoch();
const qint64 time = QDateTime::currentSecsSinceEpoch() - pause_time_.toSecsSinceEpoch();
if (time >= 30) { // Stream URL might be expired.
qLog(Debug) << "Re-requesting stream URL for" << song.url();
play_offset_nanosec_ = engine_->position_nanosec();
play_offset_nanosec_ = static_cast<quint64>(engine_->position_nanosec());
UrlHandler *url_handler = url_handlers_->GetUrlHandler(song.url());
HandleLoadResult(url_handler->StartLoading(song.url()));
return;
@@ -654,7 +654,7 @@ void Player::EngineStateChanged(const EngineBase::State state) {
switch (state) {
case EngineBase::State::Paused:
pause_time_ = QDateTime::currentDateTime();
play_offset_nanosec_ = engine_->position_nanosec();
play_offset_nanosec_ = static_cast<quint64>(engine_->position_nanosec());
Q_EMIT Paused();
break;
case EngineBase::State::Playing:
@@ -774,7 +774,7 @@ void Player::PlayAt(const int index, const bool pause, const quint64 offset_nano
}
else {
qLog(Debug) << "Playing song" << current_item_->Metadata().title() << url << "position" << offset_nanosec;
engine_->Play(current_item_->Url(), url, pause, change, current_item_->Metadata().has_cue(), current_item_->effective_beginning_nanosec(), current_item_->effective_end_nanosec(), offset_nanosec, current_item_->effective_ebur128_integrated_loudness_lufs());
engine_->Play(current_item_->Url(), url, pause, change, current_item_->Metadata().has_cue(), static_cast<quint64>(current_item_->effective_beginning_nanosec()), current_item_->effective_end_nanosec(), offset_nanosec, current_item_->effective_ebur128_integrated_loudness_lufs());
}
}
@@ -782,7 +782,7 @@ void Player::PlayAt(const int index, const bool pause, const quint64 offset_nano
void Player::CurrentMetadataChanged(const Song &metadata) {
// Those things might have changed (especially when a previously invalid song was reloaded) so we push the latest version into Engine
engine_->RefreshMarkers(metadata.beginning_nanosec(), metadata.end_nanosec());
engine_->RefreshMarkers(static_cast<quint64>(metadata.beginning_nanosec()), metadata.end_nanosec());
}
@@ -796,7 +796,7 @@ void Player::SeekTo(const quint64 seconds) {
}
const qint64 nanosec = qBound(0LL, static_cast<qint64>(seconds) * kNsecPerSec, length_nanosec);
engine_->Seek(nanosec);
engine_->Seek(static_cast<quint64>(nanosec));
qLog(Debug) << "Track seeked to" << nanosec << "ns - updating scrobble point";
playlist_manager_->active()->UpdateScrobblePoint(nanosec);
@@ -810,11 +810,11 @@ void Player::SeekTo(const quint64 seconds) {
}
void Player::SeekForward() {
SeekTo(engine()->position_nanosec() / kNsecPerSec + seek_step_sec_);
SeekTo(static_cast<quint64>(engine()->position_nanosec() / kNsecPerSec + seek_step_sec_));
}
void Player::SeekBackward() {
SeekTo(engine()->position_nanosec() / kNsecPerSec - seek_step_sec_);
SeekTo(static_cast<quint64>(engine()->position_nanosec() / kNsecPerSec - seek_step_sec_));
}
void Player::EngineMetadataReceived(const EngineMetadata &engine_metadata) {

View File

@@ -439,6 +439,7 @@ int Song::samplerate() const { return d->samplerate_; }
int Song::bitdepth() const { return d->bitdepth_; }
Song::Source Song::source() const { return d->source_; }
int Song::source_id() const { return static_cast<int>(d->source_); }
int Song::directory_id() const { return d->directory_id_; }
const QUrl &Song::url() const { return d->url_; }
const QString &Song::basefilename() const { return d->basefilename_; }
@@ -661,7 +662,8 @@ const QString &Song::playlist_albumartist() const { return is_compilation() ? d-
const QString &Song::playlist_albumartist_sortable() const { return is_compilation() ? d->albumartist_sortable_ : effective_albumartist_sortable(); }
bool Song::is_metadata_good() const { return !d->url_.isEmpty() && !d->artist_.isEmpty() && !d->title_.isEmpty(); }
bool Song::is_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_local_collection_song() const { return d->source_ == Source::Collection; }
bool Song::is_linked_collection_song() const { return IsLinkedCollectionSource(d->source_); }
bool Song::is_stream() const { return is_radio() || d->source_ == Source::Tidal || d->source_ == Source::Subsonic || d->source_ == Source::Qobuz || d->source_ == Source::Spotify; }
bool Song::is_radio() const { return d->source_ == Source::Stream || d->source_ == Source::SomaFM || d->source_ == Source::RadioParadise; }
bool Song::is_cdda() const { return d->source_ == Source::CDDA; }
@@ -713,7 +715,8 @@ bool Song::additional_tags_supported() const {
d->filetype_ == FileType::MP4 ||
d->filetype_ == FileType::MPC ||
d->filetype_ == FileType::APE ||
d->filetype_ == FileType::WAV;
d->filetype_ == FileType::WAV ||
d->filetype_ == FileType::AIFF;
}
@@ -736,7 +739,8 @@ bool Song::performer_supported() const {
d->filetype_ == FileType::MPEG ||
d->filetype_ == FileType::MPC ||
d->filetype_ == FileType::APE ||
d->filetype_ == FileType::WAV;
d->filetype_ == FileType::WAV ||
d->filetype_ == FileType::AIFF;
}
@@ -764,7 +768,9 @@ bool Song::rating_supported() const {
d->filetype_ == FileType::MP4 ||
d->filetype_ == FileType::ASF ||
d->filetype_ == FileType::MPC ||
d->filetype_ == FileType::APE;
d->filetype_ == FileType::APE ||
d->filetype_ == FileType::WAV ||
d->filetype_ == FileType::AIFF;
}
@@ -782,7 +788,9 @@ bool Song::save_embedded_cover_supported(const FileType filetype) {
filetype == FileType::OggVorbis ||
filetype == FileType::OggOpus ||
filetype == FileType::MPEG ||
filetype == FileType::MP4;
filetype == FileType::MP4 ||
filetype == FileType::WAV ||
filetype == FileType::AIFF;
}
@@ -1027,8 +1035,10 @@ bool Song::IsOnSameAlbum(const Song &other) const {
bool Song::IsSimilar(const Song &other) const {
return title().compare(other.title(), Qt::CaseInsensitive) == 0 &&
artist().compare(other.artist(), Qt::CaseInsensitive) == 0 &&
album().compare(other.album(), Qt::CaseInsensitive) == 0;
artist().compare(other.artist(), Qt::CaseInsensitive) == 0 &&
album().compare(other.album(), Qt::CaseInsensitive) == 0 &&
fingerprint().compare(other.fingerprint()) == 0 &&
acoustid_fingerprint().compare(other.acoustid_fingerprint()) == 0;
}
Song::Source Song::SourceFromURL(const QUrl &url) {
@@ -1331,6 +1341,12 @@ Song::FileType Song::FiletypeByExtension(const QString &ext) {
}
bool Song::IsLinkedCollectionSource(const Source source) {
return source == Source::Collection;
}
QString Song::ImageCacheDir(const Source source) {
switch (source) {
@@ -1831,8 +1847,8 @@ bool Song::MergeFromEngineMetadata(const EngineMetadata &engine_metadata) {
bool minor = true;
if (d->init_from_file_ || is_collection_song() || d->url_.isLocalFile()) {
// This Song was already loaded using taglib. Our tags are probably better than the engine's.
if (d->init_from_file_ || is_local_collection_song() || d->url_.isLocalFile()) {
// This Song was already loaded using TagLib. Our tags are probably better than the engine's.
if (title() != engine_metadata.title && title().isEmpty() && !engine_metadata.title.isEmpty()) {
set_title(engine_metadata.title);
minor = false;
@@ -1909,7 +1925,7 @@ size_t qHash(const Song &song) {
size_t HashSimilar(const Song &song) {
// Should compare the same fields as function IsSimilar
return qHash(song.title().toLower()) ^ qHash(song.artist().toLower()) ^ qHash(song.album().toLower());
return qHash(song.title().toLower()) ^ qHash(song.artist().toLower()) ^ qHash(song.album().toLower()) ^ qHash(song.fingerprint()) ^ qHash(song.acoustid_fingerprint());
}
bool Song::ContainsRegexList(const QString &str, const RegularExpressionList &regex_list) {

View File

@@ -78,6 +78,7 @@ class Song {
RadioParadise = 10,
Spotify = 11
};
static const int kSourceCount = 16;
enum class FileType {
Unknown = 0,
@@ -176,6 +177,7 @@ class Song {
int bitdepth() const;
Source source() const;
int source_id() const;
int directory_id() const;
const QUrl &url() const;
const QString &basefilename() const;
@@ -372,7 +374,8 @@ class Song {
const QString &playlist_albumartist_sortable() const;
bool is_metadata_good() const;
bool is_collection_song() const;
bool is_local_collection_song() const;
bool is_linked_collection_song() const;
bool is_stream() const;
bool is_radio() const;
bool is_cdda() const;
@@ -459,6 +462,7 @@ class Song {
static FileType FiletypeByMimetype(const QString &mimetype);
static FileType FiletypeByDescription(const QString &text);
static FileType FiletypeByExtension(const QString &ext);
static bool IsLinkedCollectionSource(const Source source);
static QString ImageCacheDir(const Source source);
// Sort songs alphabetically using their pretty title

View File

@@ -55,7 +55,7 @@ void StandardItemIconLoader::SetModel(QAbstractItemModel *model) {
}
void StandardItemIconLoader::LoadIcon(const QUrl &art_automatic, const QUrl &art_manual, QStandardItem *for_item) {
void StandardItemIconLoader::LoadAlbumCover(const QUrl &art_automatic, const QUrl &art_manual, QStandardItem *for_item) {
AlbumCoverLoaderOptions cover_options(AlbumCoverLoaderOptions::Option::ScaledImage);
cover_options.desired_scaled_size = QSize(16, 16);
@@ -64,7 +64,7 @@ void StandardItemIconLoader::LoadIcon(const QUrl &art_automatic, const QUrl &art
}
void StandardItemIconLoader::LoadIcon(const Song &song, QStandardItem *for_item) {
void StandardItemIconLoader::LoadAlbumCover(const Song &song, QStandardItem *for_item) {
AlbumCoverLoaderOptions cover_options(AlbumCoverLoaderOptions::Option::ScaledImage);
cover_options.desired_scaled_size = QSize(16, 16);

View File

@@ -46,8 +46,8 @@ class StandardItemIconLoader : public QObject {
void SetModel(QAbstractItemModel *model);
void LoadIcon(const QUrl &art_automatic, const QUrl &art_manual, QStandardItem *for_item);
void LoadIcon(const Song &song, QStandardItem *for_item);
void LoadAlbumCover(const QUrl &art_automatic, const QUrl &art_manual, QStandardItem *for_item);
void LoadAlbumCover(const Song &song, QStandardItem *for_item);
private Q_SLOTS:
void AlbumCoverLoaded(const quint64 id, const AlbumCoverLoaderResult &result);

View File

@@ -28,21 +28,29 @@
UrlHandler::UrlHandler(QObject *parent) : QObject(parent) {}
UrlHandler::LoadResult::LoadResult(const QUrl &media_url, const Type type, const QUrl &stream_url, const Song::FileType filetype, const int samplerate, const int bit_depth, const qint64 length_nanosec, const QString &error) : media_url_(media_url),
type_(type),
stream_url_(stream_url),
filetype_(filetype),
samplerate_(samplerate),
bit_depth_(bit_depth),
length_nanosec_(length_nanosec),
error_(error)
{}
UrlHandler::LoadResult::LoadResult(const QUrl &media_url,
const Type type,
const QUrl &stream_url,
const Song::FileType filetype,
const int samplerate,
const int bit_depth,
const qint64 length_nanosec,
const QString &error)
: media_url_(media_url),
type_(type),
stream_url_(stream_url),
filetype_(filetype),
samplerate_(samplerate),
bit_depth_(bit_depth),
length_nanosec_(length_nanosec),
error_(error) {}
UrlHandler::LoadResult::LoadResult(const QUrl &media_url, const Type type, const QString &error)
: media_url_(media_url),
type_(type),
filetype_(Song::FileType::Stream),
samplerate_(-1),
bit_depth_(-1),
length_nanosec_(-1),
error_(error) {}
UrlHandler::LoadResult::LoadResult(const QUrl &media_url, const Type type, const QString &error) : media_url_(media_url),
type_(type),
filetype_(Song::FileType::Stream),
samplerate_(-1),
bit_depth_(-1),
length_nanosec_(-1),
error_(error)
{}

View File

@@ -56,7 +56,6 @@ class UrlHandler : public QObject {
};
explicit LoadResult(const QUrl &media_url = QUrl(), const Type type = Type::NoMoreTracks, const QUrl &stream_url = QUrl(), const Song::FileType filetype = Song::FileType::Stream, const int samplerate = -1, const int bit_depth = -1, const qint64 length_nanosec = -1, const QString &error = QString());
explicit LoadResult(const QUrl &media_url, const Type type, const QString &error);
// The url that the playlist item has in Url().

View File

@@ -140,12 +140,12 @@ void Windows7ThumbBar::HandleWinEvent(MSG *msg) {
for (int i = 0; i < actions_.count(); ++i) {
const QAction *action = actions_[i];
THUMBBUTTON *button = &buttons[i];
button->iId = i;
button->iId = static_cast<UINT>(i);
SetupButton(action, button);
}
qLog(Debug) << "Adding" << actions_.count() << "buttons";
HRESULT hr = taskbar_list_->ThumbBarAddButtons(reinterpret_cast<HWND>(widget_->winId()), actions_.count(), buttons);
HRESULT hr = taskbar_list_->ThumbBarAddButtons(reinterpret_cast<HWND>(widget_->winId()), static_cast<UINT>(actions_.count()), buttons);
if (hr != S_OK) {
qLog(Debug) << "Failed to add buttons" << Qt::hex << DWORD(hr);
}
@@ -185,11 +185,11 @@ void Windows7ThumbBar::ActionChanged() {
QAction *action = actions_[i];
THUMBBUTTON *button = &buttons[i];
button->iId = i;
button->iId = static_cast<UINT>(i);
SetupButton(action, button);
}
HRESULT hr = taskbar_list_->ThumbBarUpdateButtons(reinterpret_cast<HWND>(widget_->winId()), actions_.count(), buttons);
HRESULT hr = taskbar_list_->ThumbBarUpdateButtons(reinterpret_cast<HWND>(widget_->winId()), static_cast<UINT>(actions_.count()), buttons);
if (hr != S_OK) {
qLog(Debug) << "Failed to update buttons" << Qt::hex << DWORD(hr);
}

View File

@@ -98,10 +98,10 @@ void AlbumCoverFetcherSearch::Start(SharedPtr<CoverProviders> cover_providers) {
for (CoverProvider *provider : std::as_const(cover_providers_sorted)) {
if (!provider->is_enabled()) continue;
if (!provider->enabled()) continue;
// Skip any provider that requires authentication but is not authenticated.
if (provider->AuthenticationRequired() && !provider->IsAuthenticated()) {
if (provider->authentication_required() && !provider->authenticated()) {
continue;
}
@@ -249,6 +249,8 @@ void AlbumCoverFetcherSearch::ProviderSearchFinished(const int id, const CoverPr
void AlbumCoverFetcherSearch::AllProvidersFinished() {
qLog(Debug) << "Search finished, got" << results_.count() << "results";
if (cancel_requested_) {
return;
}
@@ -285,9 +287,9 @@ void AlbumCoverFetcherSearch::FetchMoreImages() {
qLog(Debug) << "Loading" << result.artist << result.album << result.image_url << "from" << result.provider << "with current score" << result.score();
QNetworkRequest req(result.image_url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *image_reply = network_->get(req);
QNetworkRequest network_request(result.image_url);
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *image_reply = network_->get(network_request);
QObject::connect(image_reply, &QNetworkReply::finished, this, [this, image_reply]() { ProviderCoverFetchFinished(image_reply); });
pending_image_loads_[image_reply] = result;
image_load_timeout_->AddReply(image_reply);

View File

@@ -397,9 +397,9 @@ AlbumCoverLoader::LoadImageResult AlbumCoverLoader::LoadRemoteUrlImage(TaskPtr t
qLog(Debug) << "Loading remote cover from URL" << cover_url;
QNetworkRequest request(cover_url);
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(request);
QNetworkRequest network_request(cover_url);
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(network_request);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, task, result_type, cover_url]() { LoadRemoteImageFinished(reply, task, result_type, cover_url); });
return LoadImageResult(result_type, LoadImageResult::Status::Async);
@@ -418,10 +418,10 @@ void AlbumCoverLoader::LoadRemoteImageFinished(QNetworkReply *reply, TaskPtr tas
}
const QUrl redirect_url = redirect.toUrl();
qLog(Debug) << "Loading remote cover from redirected URL" << redirect_url;
QNetworkRequest request = reply->request();
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
request.setUrl(redirect_url);
QNetworkReply *redirected_reply = network_->get(request);
QNetworkRequest network_request = reply->request();
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
network_request.setUrl(redirect_url);
QNetworkReply *redirected_reply = network_->get(network_request);
QObject::connect(redirected_reply, &QNetworkReply::finished, this, [this, reply, task, result_type, redirect_url]() { LoadRemoteImageFinished(reply, task, result_type, redirect_url); });
return;
}

View File

@@ -73,10 +73,10 @@ void CoverFromURLDialog::accept() {
ui_->busy->show();
QNetworkRequest req(QUrl::fromUserInput(ui_->url->text()));
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkRequest network_request(QUrl::fromUserInput(ui_->url->text()));
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(req);
QNetworkReply *reply = network_->get(network_request);
QObject::connect(reply, &QNetworkReply::finished, this, &CoverFromURLDialog::LoadCoverFromURLFinished);
}

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
@@ -21,10 +21,9 @@
#include "config.h"
#include <QObject>
#include <QString>
#include "includes/shared_ptr.h"
#include "coverprovider.h"
CoverProvider::CoverProvider(const QString &name, const bool enabled, const bool authentication_required, const float quality, const bool batch, const bool allow_missing_album, const SharedPtr<NetworkAccessManager> network, QObject *parent) : QObject(parent), network_(network), name_(name), enabled_(enabled), order_(0), authentication_required_(authentication_required), quality_(quality), batch_(batch), allow_missing_album_(allow_missing_album) {}
CoverProvider::CoverProvider(const QString &name, const bool enabled, const bool authentication_required, const float quality, const bool batch, const bool allow_missing_album, const SharedPtr<NetworkAccessManager> network, QObject *parent) : JsonBaseRequest(network, parent), network_(network), name_(name), enabled_(enabled), order_(0), authentication_required_(authentication_required), quality_(quality), batch_(batch), allow_missing_album_(allow_missing_album) {}

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
@@ -24,28 +24,24 @@
#include "config.h"
#include <QtGlobal>
#include <QObject>
#include <QVariant>
#include <QString>
#include <QStringList>
#include "includes/shared_ptr.h"
#include "core/jsonbaserequest.h"
#include "albumcoverfetcher.h"
class NetworkAccessManager;
// Each implementation of this interface downloads covers from one online service.
// There are no limitations on what this service might be - last.fm, Amazon, Google Images - you name it.
class CoverProvider : public QObject {
class CoverProvider : public JsonBaseRequest {
Q_OBJECT
public:
explicit CoverProvider(const QString &name, const bool enabled, const bool authentication_required, const float quality, const bool batch, const bool allow_missing_album, const SharedPtr<NetworkAccessManager> network, QObject *parent);
// A name (very short description) of this provider, like "last.fm".
QString name() const { return name_; }
bool is_enabled() const { return enabled_; }
bool enabled() const { return enabled_; }
int order() const { return order_; }
float quality() const { return quality_; }
bool batch() const { return batch_; }
@@ -54,10 +50,14 @@ class CoverProvider : public QObject {
void set_enabled(const bool enabled) { enabled_ = enabled; }
void set_order(const int order) { order_ = order; }
bool AuthenticationRequired() const { return authentication_required_; }
virtual bool IsAuthenticated() const { return true; }
virtual QString service_name() const override { return name_; }
virtual bool authentication_required() const override { return authentication_required_; }
virtual bool authenticated() const override { return true; }
virtual bool use_authorization_header() const override { return false; }
virtual QByteArray authorization_header() const override { return QByteArray(); }
virtual void Authenticate() {}
virtual void Deauthenticate() {}
virtual void ClearSession() {}
// Starts searching for covers matching the given query text.
// Returns true if the query has been started, or false if an error occurred.
@@ -65,12 +65,10 @@ class CoverProvider : public QObject {
virtual bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) = 0;
virtual void CancelSearch(const int id) { Q_UNUSED(id); }
virtual void Error(const QString &error, const QVariant &debug = QVariant()) = 0;
Q_SIGNALS:
void AuthenticationComplete(const bool success, const QStringList &errors = QStringList());
void AuthenticationFinished(const bool success, const QString &error = QString());
void AuthenticationSuccess();
void AuthenticationFailure(const QStringList &errors);
void AuthenticationFailure(const QString &error);
void SearchResults(const int id, const CoverProviderSearchResults &results);
void SearchFinished(const int id, const CoverProviderSearchResults &results);

View File

@@ -55,7 +55,7 @@ void CoverProviders::ReloadSettings() {
QMap<int, QString> all_providers;
QList<CoverProvider*> old_providers = cover_providers_.keys();
for (CoverProvider *provider : std::as_const(old_providers)) {
if (!provider->is_enabled()) continue;
if (!provider->enabled()) continue;
all_providers.insert(provider->order(), provider->name());
}

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -22,8 +22,6 @@
#include <algorithm>
#include <utility>
#include <QtGlobal>
#include <QObject>
#include <QPair>
#include <QList>
#include <QMap>
@@ -38,6 +36,7 @@
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QScopeGuard>
#include "core/networkaccessmanager.h"
#include "core/logging.h"
@@ -52,22 +51,11 @@ using namespace Qt::Literals::StringLiterals;
namespace {
constexpr char kApiUrl[] = "https://api.deezer.com";
constexpr int kLimit = 10;
}
} // namespace
DeezerCoverProvider::DeezerCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: JsonCoverProvider(u"Deezer"_s, true, false, 2.0, true, true, network, parent) {}
DeezerCoverProvider::~DeezerCoverProvider() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
bool DeezerCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
@@ -91,17 +79,7 @@ bool DeezerCoverProvider::StartSearch(const QString &artist, const QString &albu
<< Param(u"q"_s, query)
<< Param(u"limit"_s, QString::number(kLimit));
QUrlQuery url_query;
for (const Param &param : params) {
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
}
QUrl url(QLatin1String(kApiUrl) + QLatin1Char('/') + resource);
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(req);
replies_ << reply;
QNetworkReply *reply = CreateGetRequest(QUrl(QLatin1String(kApiUrl) + QLatin1Char('/') + resource), params);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { HandleSearchReply(reply, id); });
return true;
@@ -110,82 +88,56 @@ bool DeezerCoverProvider::StartSearch(const QString &artist, const QString &albu
void DeezerCoverProvider::CancelSearch(const int id) { Q_UNUSED(id); }
QByteArray DeezerCoverProvider::GetReplyData(QNetworkReply *reply) {
JsonBaseRequest::JsonObjectResult DeezerCoverProvider::ParseJsonObject(QNetworkReply *reply) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
data = reply->readAll();
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
Error(error);
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object["error"_L1].isObject()) {
const QJsonObject object_error = json_object["error"_L1].toObject();
if (object_error.contains("code"_L1) && object_error.contains("type"_L1) && object_error.contains("message"_L1)) {
const int code = object_error["code"_L1].toInt();
const QString type = object_error["type"_L1].toString();
const QString message = object_error["message"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1: %2 (%3)").arg(type, message).arg(code);
}
}
else {
result.json_object = json_document.object();
}
}
else {
// See if there is Json data containing "error" object - then use that instead.
data = reply->readAll();
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
QString error;
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (json_obj.contains("error"_L1)) {
QJsonValue value_error = json_obj["error"_L1];
if (value_error.isObject()) {
QJsonObject obj_error = value_error.toObject();
int code = obj_error["code"_L1].toInt();
QString message = obj_error["message"_L1].toString();
error = QStringLiteral("%1 (%2)").arg(message).arg(code);
}
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
Error(error);
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
return QByteArray();
}
return data;
}
QJsonValue DeezerCoverProvider::ExtractData(const QByteArray &data) {
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) return QJsonObject();
if (json_obj.contains("error"_L1)) {
QJsonValue value_error = json_obj["error"_L1];
if (!value_error.isObject()) {
Error(u"Error missing object"_s, json_obj);
return QJsonValue();
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
QJsonObject obj_error = value_error.toObject();
const int code = obj_error["code"_L1].toInt();
QString message = obj_error["message"_L1].toString();
Error(QStringLiteral("%1 (%2)").arg(message).arg(code));
return QJsonValue();
}
if (!json_obj.contains("data"_L1) && !json_obj.contains("DATA"_L1)) {
Error(u"Json reply object is missing data."_s, json_obj);
return QJsonValue();
}
QJsonValue value_data;
if (json_obj.contains("data"_L1)) value_data = json_obj["data"_L1];
else value_data = json_obj["DATA"_L1];
return value_data;
return result;
}
@@ -196,78 +148,90 @@ void DeezerCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id)
QObject::disconnect(reply, nullptr, this, nullptr);
reply->deleteLater();
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
CoverProviderSearchResults results;
const QScopeGuard search_finished = qScopeGuard([this, id, &results]() { Q_EMIT SearchFinished(id, results); });
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonValue value_data = ExtractData(data);
if (!value_data.isArray()) {
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
QJsonArray array_data;
if (json_object.contains("data"_L1) && json_object["data"_L1].isArray()) {
array_data = json_object["data"_L1].toArray();
}
else if (json_object.contains("DATA"_L1) && json_object["DATA"_L1].isArray()) {
array_data = json_object["DATA"_L1].toArray();
}
else {
Error(u"Json reply object is missing data."_s, json_object);
return;
}
QJsonArray array_data = value_data.toArray();
if (array_data.isEmpty()) {
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
return;
}
QMap<QUrl, CoverProviderSearchResult> results;
QMap<QUrl, CoverProviderSearchResult> cover_results;
int i = 0;
for (const QJsonValue &json_value : std::as_const(array_data)) {
for (const QJsonValue &value_entry : std::as_const(array_data)) {
if (!json_value.isObject()) {
if (!value_entry.isObject()) {
Error(u"Invalid Json reply, data array value is not a object."_s);
continue;
}
QJsonObject json_obj = json_value.toObject();
QJsonObject obj_album;
if (json_obj.contains("album"_L1) && json_obj["album"_L1].isObject()) { // Song search, so extract the album.
obj_album = json_obj["album"_L1].toObject();
const QJsonObject object_entry = value_entry.toObject();
QJsonObject object_album;
if (object_entry.contains("album"_L1) && object_entry["album"_L1].isObject()) { // Song search, so extract the album.
object_album = object_entry["album"_L1].toObject();
}
else {
obj_album = json_obj;
object_album = object_entry;
}
if (!json_obj.contains("id"_L1) || !obj_album.contains("id"_L1)) {
Error(u"Invalid Json reply, data array value object is missing ID."_s, json_obj);
if (!object_entry.contains("id"_L1) || !object_album.contains("id"_L1)) {
Error(u"Invalid Json reply, data array value object is missing ID."_s, object_entry);
continue;
}
if (!obj_album.contains("type"_L1)) {
Error(u"Invalid Json reply, data array value album object is missing type."_s, obj_album);
if (!object_album.contains("type"_L1)) {
Error(u"Invalid Json reply, data array value album object is missing type."_s, object_album);
continue;
}
QString type = obj_album["type"_L1].toString();
const QString type = object_album["type"_L1].toString();
if (type != "album"_L1) {
Error(u"Invalid Json reply, data array value album object has incorrect type returned"_s, obj_album);
Error(u"Invalid Json reply, data array value album object has incorrect type returned"_s, object_album);
continue;
}
if (!json_obj.contains("artist"_L1)) {
Error(u"Invalid Json reply, data array value object is missing artist."_s, json_obj);
if (!object_entry.contains("artist"_L1)) {
Error(u"Invalid Json reply, data array value object is missing artist."_s, object_entry);
continue;
}
QJsonValue value_artist = json_obj["artist"_L1];
const QJsonValue value_artist = object_entry["artist"_L1];
if (!value_artist.isObject()) {
Error(u"Invalid Json reply, data array value artist is not a object."_s, value_artist);
continue;
}
QJsonObject obj_artist = value_artist.toObject();
const QJsonObject object_artist = value_artist.toObject();
if (!obj_artist.contains("name"_L1)) {
Error(u"Invalid Json reply, data array value artist object is missing name."_s, obj_artist);
if (!object_artist.contains("name"_L1)) {
Error(u"Invalid Json reply, data array value artist object is missing name."_s, object_artist);
continue;
}
QString artist = obj_artist["name"_L1].toString();
const QString artist = object_artist["name"_L1].toString();
if (!obj_album.contains("title"_L1)) {
Error(u"Invalid Json reply, data array value album object is missing title."_s, obj_album);
if (!object_album.contains("title"_L1)) {
Error(u"Invalid Json reply, data array value album object is missing title."_s, object_album);
continue;
}
QString album = obj_album["title"_L1].toString();
const QString album = object_album["title"_L1].toString();
CoverProviderSearchResult cover_result;
cover_result.artist = artist;
@@ -277,35 +241,29 @@ void DeezerCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id)
const QList<QPair<QString, QSize>> cover_sizes = QList<QPair<QString, QSize>>() << qMakePair(u"cover_xl"_s, QSize(1000, 1000))
<< qMakePair(u"cover_big"_s, QSize(500, 500));
for (const QPair<QString, QSize> &cover_size : cover_sizes) {
if (!obj_album.contains(cover_size.first)) continue;
QString cover = obj_album[cover_size.first].toString();
if (!object_album.contains(cover_size.first)) continue;
const QString cover = object_album[cover_size.first].toString();
if (!have_cover) {
have_cover = true;
++i;
}
QUrl url(cover);
if (!results.contains(url)) {
const QUrl url(cover);
if (!cover_results.contains(url)) {
cover_result.image_url = url;
cover_result.image_size = cover_size.second;
cover_result.number = i;
results.insert(url, cover_result);
cover_results.insert(url, cover_result);
}
}
if (!have_cover) {
Error(u"Invalid Json reply, data array value album object is missing cover."_s, obj_album);
Error(u"Invalid Json reply, data array value album object is missing cover."_s, object_album);
}
}
if (results.isEmpty()) {
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
}
else {
CoverProviderSearchResults cover_results = results.values();
std::stable_sort(cover_results.begin(), cover_results.end(), AlbumCoverFetcherSearch::CoverProviderSearchResultCompareNumber);
Q_EMIT SearchFinished(id, cover_results);
}
results = cover_results.values();
std::stable_sort(results.begin(), results.end(), AlbumCoverFetcherSearch::CoverProviderSearchResultCompareNumber);
}

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -22,13 +22,8 @@
#include "config.h"
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QJsonValue>
#include <QJsonObject>
#include "jsoncoverprovider.h"
@@ -40,7 +35,6 @@ class DeezerCoverProvider : public JsonCoverProvider {
public:
explicit DeezerCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~DeezerCoverProvider() override;
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
void CancelSearch(const int id) override;
@@ -48,13 +42,11 @@ class DeezerCoverProvider : public JsonCoverProvider {
private Q_SLOTS:
void HandleSearchReply(QNetworkReply *reply, const int id);
private:
QByteArray GetReplyData(QNetworkReply *reply);
QJsonValue ExtractData(const QByteArray &data);
void Error(const QString &error, const QVariant &debug = QVariant()) override;
protected:
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
private:
QList<QNetworkReply*> replies_;
void Error(const QString &error, const QVariant &debug = QVariant()) override;
};
#endif // DEEZERCOVERPROVIDER_H

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, Martin Björklund <mbj4668@gmail.com>
* Copyright 2016-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2016-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
@@ -24,8 +24,6 @@
#include <memory>
#include <algorithm>
#include <QtGlobal>
#include <QObject>
#include <QByteArray>
#include <QPair>
#include <QVariant>
@@ -40,6 +38,7 @@
#include <QJsonObject>
#include <QJsonValue>
#include <QJsonArray>
#include <QScopeGuard>
#include "includes/shared_ptr.h"
#include "core/logging.h"
@@ -146,81 +145,88 @@ void DiscogsCoverProvider::SendSearchRequest(SharedPtr<DiscogsCoverSearchContext
}
QNetworkReply *DiscogsCoverProvider::CreateRequest(QUrl url, const ParamList &params_provided) {
QNetworkReply *DiscogsCoverProvider::CreateRequest(const QUrl &url, const ParamList &params) {
const ParamList params = ParamList() << Param(u"key"_s, QString::fromLatin1(QByteArray::fromBase64(kAccessKeyB64)))
<< Param(u"secret"_s, QString::fromLatin1(QByteArray::fromBase64(kSecretKeyB64)))
<< params_provided;
const ParamList request_params = ParamList() << Param(u"key"_s, QString::fromLatin1(QByteArray::fromBase64(kAccessKeyB64)))
<< Param(u"secret"_s, QString::fromLatin1(QByteArray::fromBase64(kSecretKeyB64)))
<< params;
QUrlQuery url_query;
QStringList query_items;
// Encode the arguments
using EncodedParam = QPair<QByteArray, QByteArray>;
for (const Param &param : params) {
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
for (const Param &param : request_params) {
const EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
query_items << QString::fromLatin1(encoded_param.first) + QLatin1Char('=') + QString::fromLatin1(encoded_param.second);
url_query.addQueryItem(QString::fromLatin1(encoded_param.first), QString::fromLatin1(encoded_param.second));
}
url.setQuery(url_query);
QUrl request_url(url);
request_url.setQuery(url_query);
// Sign the request
const QByteArray data_to_sign = QStringLiteral("GET\n%1\n%2\n%3").arg(url.host(), url.path(), query_items.join(u'&')).toUtf8();
const QByteArray data_to_sign = QStringLiteral("GET\n%1\n%2\n%3").arg(request_url.host(), request_url.path(), query_items.join(u'&')).toUtf8();
const QByteArray signature(Utilities::HmacSha256(QByteArray::fromBase64(kSecretKeyB64), data_to_sign));
// Add the signature to the request
url_query.addQueryItem(u"Signature"_s, QString::fromLatin1(QUrl::toPercentEncoding(QString::fromLatin1(signature.toBase64()))));
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(req);
QNetworkRequest network_request(request_url);
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(network_request);
replies_ << reply;
qLog(Debug) << "Discogs: Sending request" << url;
qLog(Debug) << "Discogs: Sending request" << request_url;
return reply;
}
QByteArray DiscogsCoverProvider::GetReplyData(QNetworkReply *reply) {
JsonBaseRequest::JsonObjectResult DiscogsCoverProvider::ParseJsonObject(QNetworkReply *reply) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
data = reply->readAll();
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
Error(error);
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("message"_L1)) {
result.error_code = ErrorCode::APIError;
result.error_message = json_object["message"_L1].toString();
}
else {
result.json_object = json_document.object();
}
}
else {
// See if there is Json data containing "message" - then use that instead.
data = reply->readAll();
QString error;
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (json_obj.contains("message"_L1)) {
error = json_obj["message"_L1].toString();
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
Error(error);
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
return QByteArray();
}
return data;
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
return result;
}
@@ -234,37 +240,34 @@ void DiscogsCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id)
if (!requests_search_.contains(id)) return;
SharedPtr<DiscogsCoverSearchContext> search = requests_search_.value(id);
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
EndSearch(search);
const QScopeGuard end_search = qScopeGuard([this, search]() { EndSearch(search); });
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) {
EndSearch(search);
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
QJsonValue value_results;
if (json_obj.contains("results"_L1)) {
value_results = json_obj["results"_L1];
if (json_object.contains("results"_L1)) {
value_results = json_object["results"_L1];
}
else if (json_obj.contains("message"_L1)) {
QString message = json_obj["message"_L1].toString();
Error(QStringLiteral("%1").arg(message));
EndSearch(search);
else if (json_object.contains("message"_L1)) {
Error(json_object["message"_L1].toString());
return;
}
else {
Error(u"Json object is missing results."_s, json_obj);
EndSearch(search);
Error(u"Json object is missing results."_s, json_object);
return;
}
if (!value_results.isArray()) {
Error(u"Missing results array."_s, value_results);
EndSearch(search);
return;
}
@@ -275,19 +278,19 @@ void DiscogsCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id)
Error(u"Invalid Json reply, results value is not a object."_s);
continue;
}
QJsonObject obj_result = value_result.toObject();
if (!obj_result.contains("id"_L1) || !obj_result.contains("title"_L1) || !obj_result.contains("resource_url"_L1)) {
Error(QStringLiteral("Invalid Json reply, results value object is missing ID, title or resource_url."), obj_result);
const QJsonObject object_result = value_result.toObject();
if (!object_result.contains("id"_L1) || !object_result.contains("title"_L1) || !object_result.contains("resource_url"_L1)) {
Error(QStringLiteral("Invalid Json reply, results value object is missing ID, title or resource_url."), object_result);
continue;
}
quint64 release_id = obj_result["id"_L1].toInt();
QUrl resource_url(obj_result["resource_url"_L1].toString());
QString title = obj_result["title"_L1].toString();
const quint64 release_id = static_cast<quint64>(object_result["id"_L1].toInt());
const QUrl resource_url(object_result["resource_url"_L1].toString());
QString title = object_result["title"_L1].toString();
if (title.contains(" - "_L1)) {
QStringList title_splitted = title.split(u" - "_s);
if (title_splitted.count() == 2) {
QString artist = title_splitted.first();
const QString artist = title_splitted.first();
title = title_splitted.last();
if (artist.compare(search->artist, Qt::CaseInsensitive) != 0 && title.compare(search->album, Qt::CaseInsensitive) != 0) continue;
}
@@ -300,14 +303,9 @@ void DiscogsCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id)
StartReleaseRequest(search, release_id, resource_url);
}
if (search->requests_release_.count() == 0) {
if (search->type == DiscogsCoverType::Master) {
search->type = DiscogsCoverType::Release;
queue_search_requests_.enqueue(search);
}
else {
EndSearch(search);
}
if (search->requests_release_.count() == 0 && search->type == DiscogsCoverType::Master) {
search->type = DiscogsCoverType::Release;
queue_search_requests_.enqueue(search);
}
}
@@ -344,33 +342,31 @@ void DiscogsCoverProvider::HandleReleaseReply(QNetworkReply *reply, const int se
if (!search->requests_release_.contains(release_id)) return;
const DiscogsCoverReleaseContext &release = search->requests_release_.value(release_id);
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
EndSearch(search, release.id);
const QScopeGuard end_search = qScopeGuard([this, search, release]() { EndSearch(search, release.id); });
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) {
EndSearch(search, release.id);
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
if (!json_obj.contains("artists"_L1) || !json_obj.contains("title"_L1)) {
Error(u"Json reply object is missing artists or title."_s, json_obj);
EndSearch(search, release.id);
if (!json_object.contains("artists"_L1) || !json_object.contains("title"_L1)) {
Error(u"Json reply object is missing artists or title."_s, json_object);
return;
}
if (!json_obj.contains("images"_L1)) {
EndSearch(search, release.id);
if (!json_object.contains("images"_L1)) {
return;
}
QJsonValue value_artists = json_obj["artists"_L1];
const QJsonValue value_artists = json_object["artists"_L1];
if (!value_artists.isArray()) {
Error(u"Json reply object artists is not a array."_s, value_artists);
EndSearch(search, release.id);
return;
}
const QJsonArray array_artists = value_artists.toArray();
@@ -381,39 +377,35 @@ void DiscogsCoverProvider::HandleReleaseReply(QNetworkReply *reply, const int se
Error(u"Invalid Json reply, atists array value is not a object."_s);
continue;
}
QJsonObject obj_artist = value_artist.toObject();
if (!obj_artist.contains("name"_L1)) {
Error(u"Invalid Json reply, artists array value object is missing name."_s, obj_artist);
const QJsonObject object_artist = value_artist.toObject();
if (!object_artist.contains("name"_L1)) {
Error(u"Invalid Json reply, artists array value object is missing name."_s, object_artist);
continue;
}
artist = obj_artist["name"_L1].toString();
artist = object_artist["name"_L1].toString();
++i;
if (artist == search->artist) break;
}
if (artist.isEmpty()) {
EndSearch(search, release.id);
return;
}
if (i > 1 && artist != search->artist) artist = "Various artists"_L1;
QString album = json_obj["title"_L1].toString();
const QString album = json_object["title"_L1].toString();
if (artist != search->artist && album != search->album) {
EndSearch(search, release.id);
return;
}
QJsonValue value_images = json_obj["images"_L1];
const QJsonValue value_images = json_object["images"_L1];
if (!value_images.isArray()) {
Error(u"Json images is not an array."_s);
EndSearch(search, release.id);
return;
}
const QJsonArray array_images = value_images.toArray();
if (array_images.isEmpty()) {
Error(u"Invalid Json reply, images array is empty."_s);
EndSearch(search, release.id);
return;
}
@@ -423,17 +415,17 @@ void DiscogsCoverProvider::HandleReleaseReply(QNetworkReply *reply, const int se
Error(u"Invalid Json reply, images array value is not an object."_s);
continue;
}
QJsonObject obj_image = value_image.toObject();
const QJsonObject obj_image = value_image.toObject();
if (!obj_image.contains("type"_L1) || !obj_image.contains("resource_url"_L1) || !obj_image.contains("width"_L1) || !obj_image.contains("height"_L1)) {
Error(u"Invalid Json reply, images array value object is missing type, resource_url, width or height."_s, obj_image);
continue;
}
QString type = obj_image["type"_L1].toString();
const QString type = obj_image["type"_L1].toString();
if (type != "primary"_L1) {
continue;
}
int width = obj_image["width"_L1].toInt();
int height = obj_image["height"_L1].toInt();
const int width = obj_image["width"_L1].toInt();
const int height = obj_image["height"_L1].toInt();
if (width < 300 || height < 300) continue;
const float aspect_score = static_cast<float>(1.0) - static_cast<float>(std::max(width, height) - std::min(width, height)) / static_cast<float>(std::max(height, width));
if (aspect_score < 0.85) continue;
@@ -448,8 +440,6 @@ void DiscogsCoverProvider::HandleReleaseReply(QNetworkReply *reply, const int se
Q_EMIT SearchResults(search->id, search->results);
search->results.clear();
EndSearch(search, release.id);
}
void DiscogsCoverProvider::EndSearch(SharedPtr<DiscogsCoverSearchContext> search, const quint64 release_id) {

View File

@@ -2,7 +2,7 @@
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, Martin Björklund <mbj4668@gmail.com>
* Copyright 2016-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2016-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
@@ -24,16 +24,11 @@
#include "config.h"
#include <QObject>
#include <QMetaType>
#include <QPair>
#include <QList>
#include <QQueue>
#include <QMap>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QJsonObject>
#include "includes/shared_ptr.h"
#include "jsoncoverprovider.h"
@@ -59,7 +54,7 @@ class DiscogsCoverProvider : public JsonCoverProvider {
};
struct DiscogsCoverReleaseContext {
explicit DiscogsCoverReleaseContext(const quint64 _search_id = 0, const quint64 _id = 0, const QUrl &_url = QUrl()) : search_id(_search_id), id(_id), url(_url) {}
explicit DiscogsCoverReleaseContext(const int _search_id = 0, const quint64 _id = 0, const QUrl &_url = QUrl()) : search_id(_search_id), id(_id), url(_url) {}
int search_id;
quint64 id;
QUrl url;
@@ -77,9 +72,9 @@ class DiscogsCoverProvider : public JsonCoverProvider {
private:
void SendSearchRequest(SharedPtr<DiscogsCoverSearchContext> search);
void SendReleaseRequest(const DiscogsCoverReleaseContext &release);
QNetworkReply *CreateRequest(QUrl url, const ParamList &params_provided = ParamList());
QByteArray GetReplyData(QNetworkReply *reply);
QNetworkReply *CreateRequest(const QUrl &url, const ParamList &params = ParamList());
void StartReleaseRequest(SharedPtr<DiscogsCoverSearchContext> search, const quint64 release_id, const QUrl &url);
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
void EndSearch(SharedPtr<DiscogsCoverSearchContext> search, const quint64 release_id = 0);
void Error(const QString &error, const QVariant &debug = QVariant()) override;
@@ -93,7 +88,6 @@ class DiscogsCoverProvider : public JsonCoverProvider {
QQueue<SharedPtr<DiscogsCoverSearchContext>> queue_search_requests_;
QQueue<DiscogsCoverReleaseContext> queue_release_requests_;
QMap<int, SharedPtr<DiscogsCoverSearchContext>> requests_search_;
QList<QNetworkReply*> replies_;
};
Q_DECLARE_METATYPE(DiscogsCoverProvider::DiscogsCoverSearchContext)

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -19,10 +19,10 @@
#include "config.h"
#include <QObject>
#include <QByteArray>
#include <QString>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QJsonObject>
#include "includes/shared_ptr.h"
@@ -35,32 +35,31 @@ using namespace Qt::Literals::StringLiterals;
JsonCoverProvider::JsonCoverProvider(const QString &name, const bool enabled, const bool authentication_required, const float quality, const bool batch, const bool allow_missing_album, const SharedPtr<NetworkAccessManager> network, QObject *parent)
: CoverProvider(name, enabled, authentication_required, quality, batch, allow_missing_album, network, parent) {}
QJsonObject JsonCoverProvider::ExtractJsonObj(const QByteArray &data) {
QJsonObject JsonCoverProvider::ExtractJsonObject(const QByteArray &data) {
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_error);
if (json_error.error != QJsonParseError::NoError) {
Error(QStringLiteral("Failed to parse json data: %1").arg(json_error.errorString()));
Error(QStringLiteral("Failed to parse Json data: %1").arg(json_error.errorString()));
return QJsonObject();
}
if (json_doc.isEmpty()) {
if (json_document.isEmpty()) {
Error(u"Received empty Json document."_s, data);
return QJsonObject();
}
if (!json_doc.isObject()) {
Error(u"Json document is not an object."_s, json_doc);
if (!json_document.isObject()) {
Error(u"Json document is not an object."_s, json_document);
return QJsonObject();
}
QJsonObject json_obj = json_doc.object();
if (json_obj.isEmpty()) {
Error(u"Received empty Json object."_s, json_doc);
const QJsonObject json_object = json_document.object();
if (json_object.isEmpty()) {
Error(u"Received empty Json object."_s, json_document);
return QJsonObject();
}
return json_obj;
return json_object;
}

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -22,7 +22,6 @@
#include "config.h"
#include <QObject>
#include <QByteArray>
#include <QString>
#include <QJsonObject>
@@ -39,7 +38,7 @@ class JsonCoverProvider : public CoverProvider {
explicit JsonCoverProvider(const QString &name, const bool enabled, const bool authentication_required, const float quality, const bool batch, const bool allow_missing_album, const SharedPtr<NetworkAccessManager> network, QObject *parent);
protected:
QJsonObject ExtractJsonObj(const QByteArray &data);
QJsonObject ExtractJsonObject(const QByteArray &data);
};
#endif // JSONCOVERPROVIDER_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -22,10 +22,7 @@
#include <algorithm>
#include <utility>
#include <QObject>
#include <QLocale>
#include <QList>
#include <QPair>
#include <QByteArray>
#include <QString>
#include <QUrl>
@@ -37,6 +34,7 @@
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QScopeGuard>
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"
@@ -57,17 +55,6 @@ constexpr char kSecret[] = "80fd738f49596e9709b1bf9319c444a8";
LastFmCoverProvider::LastFmCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: JsonCoverProvider(u"Last.fm"_s, true, false, 1.0, true, false, network, parent) {}
LastFmCoverProvider::~LastFmCoverProvider() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
bool LastFmCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
@@ -111,18 +98,62 @@ bool LastFmCoverProvider::StartSearch(const QString &artist, const QString &albu
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(u"api_sig"_s)), QString::fromLatin1(QUrl::toPercentEncoding(signature)));
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(u"format"_s)), QString::fromLatin1(QUrl::toPercentEncoding(u"json"_s)));
QUrl url(QString::fromLatin1(kUrl));
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
QNetworkReply *reply = network_->post(req, url_query.toString(QUrl::FullyEncoded).toUtf8());
replies_ << reply;
QNetworkReply *reply = CreatePostRequest(QUrl(QLatin1String(kUrl)), url_query);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, id, type]() { QueryFinished(reply, id, type); });
return true;
}
JsonBaseRequest::JsonObjectResult LastFmCoverProvider::ParseJsonObject(QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object.contains("message"_L1)) {
const int error = json_object["error"_L1].toInt();
const QString message = json_object["message"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(message).arg(error);
}
else {
result.json_object = json_document.object();
}
}
else {
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
}
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
return result;
}
void LastFmCoverProvider::QueryFinished(QNetworkReply *reply, const int id, const QString &type) {
if (!replies_.contains(reply)) return;
@@ -131,96 +162,85 @@ void LastFmCoverProvider::QueryFinished(QNetworkReply *reply, const int id, cons
reply->deleteLater();
CoverProviderSearchResults results;
const QScopeGuard end_search = qScopeGuard([this, id, &results]() { Q_EMIT SearchFinished(id, results); });
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
Q_EMIT SearchFinished(id, results);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) {
Q_EMIT SearchFinished(id, results);
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
QJsonValue value_results;
if (json_obj.contains("results"_L1)) {
value_results = json_obj["results"_L1];
if (json_object.contains("results"_L1)) {
value_results = json_object["results"_L1];
}
else if (json_obj.contains("error"_L1) && json_obj.contains("message"_L1)) {
int error = json_obj["error"_L1].toInt();
QString message = json_obj["message"_L1].toString();
else if (json_object.contains("error"_L1) && json_object.contains("message"_L1)) {
const int error = json_object["error"_L1].toInt();
const QString message = json_object["message"_L1].toString();
Error(QStringLiteral("Error: %1: %2").arg(QString::number(error), message));
Q_EMIT SearchFinished(id, results);
return;
}
else {
Error(u"Json reply is missing results."_s, json_obj);
Q_EMIT SearchFinished(id, results);
Error(u"Json reply is missing results."_s, json_object);
return;
}
if (!value_results.isObject()) {
Error(u"Json results is not a object."_s, value_results);
Q_EMIT SearchFinished(id, results);
return;
}
QJsonObject obj_results = value_results.toObject();
if (obj_results.isEmpty()) {
const QJsonObject object_results = value_results.toObject();
if (object_results.isEmpty()) {
Error(u"Json results object is empty."_s, value_results);
Q_EMIT SearchFinished(id, results);
return;
}
QJsonValue value_matches;
if (type == "album"_L1) {
if (obj_results.contains("albummatches"_L1)) {
value_matches = obj_results["albummatches"_L1];
if (object_results.contains("albummatches"_L1)) {
value_matches = object_results["albummatches"_L1];
}
else {
Error(u"Json results object is missing albummatches."_s, obj_results);
Q_EMIT SearchFinished(id, results);
Error(u"Json results object is missing albummatches."_s, object_results);
return;
}
}
else if (type == "track"_L1) {
if (obj_results.contains("trackmatches"_L1)) {
value_matches = obj_results["trackmatches"_L1];
if (object_results.contains("trackmatches"_L1)) {
value_matches = object_results["trackmatches"_L1];
}
else {
Error(u"Json results object is missing trackmatches."_s, obj_results);
Q_EMIT SearchFinished(id, results);
Error(u"Json results object is missing trackmatches."_s, object_results);
return;
}
}
if (!value_matches.isObject()) {
Error(u"Json albummatches or trackmatches is not an object."_s, value_matches);
Q_EMIT SearchFinished(id, results);
return;
}
QJsonObject obj_matches = value_matches.toObject();
if (obj_matches.isEmpty()) {
const QJsonObject object_matches = value_matches.toObject();
if (object_matches.isEmpty()) {
Error(u"Json albummatches or trackmatches object is empty."_s, value_matches);
Q_EMIT SearchFinished(id, results);
return;
}
QJsonValue value_type;
if (!obj_matches.contains(type)) {
Error(QStringLiteral("Json object is missing %1.").arg(type), obj_matches);
Q_EMIT SearchFinished(id, results);
if (!object_matches.contains(type)) {
Error(QStringLiteral("Json object is missing %1.").arg(type), object_matches);
return;
}
value_type = obj_matches[type];
const QJsonValue value_type = object_matches[type];
if (!value_type.isArray()) {
Error(u"Json album value in albummatches object is not an array."_s, value_type);
Q_EMIT SearchFinished(id, results);
return;
}
const QJsonArray array_type = value_type.toArray();
@@ -231,23 +251,22 @@ void LastFmCoverProvider::QueryFinished(QNetworkReply *reply, const int id, cons
Error(u"Invalid Json reply, value in albummatches/trackmatches array is not a object."_s);
continue;
}
QJsonObject obj = value.toObject();
if (!obj.contains("artist"_L1) || !obj.contains("image"_L1) || !obj.contains("name"_L1)) {
Error(u"Invalid Json reply, album is missing artist, image or name."_s, obj);
const QJsonObject object = value.toObject();
if (!object.contains("artist"_L1) || !object.contains("image"_L1) || !object.contains("name"_L1)) {
Error(u"Invalid Json reply, album is missing artist, image or name."_s, object);
continue;
}
QString artist = obj["artist"_L1].toString();
const QString artist = object["artist"_L1].toString();
QString album;
if (type == "album"_L1) {
album = obj["name"_L1].toString();
album = object["name"_L1].toString();
}
QJsonValue json_image = obj["image"_L1];
if (!json_image.isArray()) {
Error(u"Invalid Json reply, album image is not a array."_s, json_image);
if (!object.contains("image"_L1) || !object["image"_L1].isArray()) {
Error(u"Invalid Json reply, album image is not a array."_s, object);
continue;
}
const QJsonArray array_image = json_image.toArray();
const QJsonArray array_image = object["image"_L1].toArray();
QString image_url_use;
LastFmImageSize image_size_use = LastFmImageSize::Unknown;
for (const QJsonValue &value_image : array_image) {
@@ -255,14 +274,14 @@ void LastFmCoverProvider::QueryFinished(QNetworkReply *reply, const int id, cons
Error(u"Invalid Json reply, album image value is not an object."_s);
continue;
}
QJsonObject obj_image = value_image.toObject();
if (!obj_image.contains("#text"_L1) || !obj_image.contains("size"_L1)) {
Error(u"Invalid Json reply, album image value is missing #text or size."_s, obj_image);
const QJsonObject object_image = value_image.toObject();
if (!object_image.contains("#text"_L1) || !object_image.contains("size"_L1)) {
Error(u"Invalid Json reply, album image value is missing #text or size."_s, object_image);
continue;
}
QString image_url = obj_image["#text"_L1].toString();
const QString image_url = object_image["#text"_L1].toString();
if (image_url.isEmpty()) continue;
LastFmImageSize image_size = ImageSizeFromString(obj_image["size"_L1].toString().toLower());
const LastFmImageSize image_size = ImageSizeFromString(object_image["size"_L1].toString().toLower());
if (image_url_use.isEmpty() || image_size > image_size_use) {
image_url_use = image_url;
image_size_use = image_size;
@@ -275,7 +294,7 @@ void LastFmCoverProvider::QueryFinished(QNetworkReply *reply, const int id, cons
if (image_url_use.contains("/300x300/"_L1)) {
image_url_use = image_url_use.replace("/300x300/"_L1, "/740x0/"_L1);
}
QUrl url(image_url_use);
const QUrl url(image_url_use);
if (!url.isValid()) continue;
CoverProviderSearchResult cover_result;
@@ -285,50 +304,6 @@ void LastFmCoverProvider::QueryFinished(QNetworkReply *reply, const int id, cons
cover_result.image_size = QSize(300, 300);
results << cover_result;
}
Q_EMIT SearchFinished(id, results);
}
QByteArray LastFmCoverProvider::GetReplyData(QNetworkReply *reply) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
data = reply->readAll();
}
else {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
Error(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
else {
// See if there is Json data containing "error" and "message" - then use that instead.
data = reply->readAll();
QString error;
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (json_obj.contains("error"_L1) && json_obj.contains("message"_L1)) {
int code = json_obj["error"_L1].toInt();
QString message = json_obj["message"_L1].toString();
error = "Error: "_L1 + QString::number(code) + ": "_L1 + message;
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
Error(error);
}
return QByteArray();
}
return data;
}

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -22,12 +22,8 @@
#include "config.h"
#include <QObject>
#include <QList>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QJsonObject>
#include "includes/shared_ptr.h"
#include "jsoncoverprovider.h"
@@ -40,13 +36,17 @@ class LastFmCoverProvider : public JsonCoverProvider {
public:
explicit LastFmCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~LastFmCoverProvider() override;
bool authentication_required() const override { return true; }
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
private Q_SLOTS:
void QueryFinished(QNetworkReply *reply, const int id, const QString &type);
protected:
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
private:
enum class LastFmImageSize {
Unknown,
@@ -56,12 +56,8 @@ class LastFmCoverProvider : public JsonCoverProvider {
ExtraLarge = 300
};
QByteArray GetReplyData(QNetworkReply *reply);
static LastFmImageSize ImageSizeFromString(const QString &size);
void Error(const QString &error, const QVariant &debug = QVariant()) override;
private:
QList<QNetworkReply*> replies_;
};
#endif // LASTFMCOVERPROVIDER_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -19,22 +19,18 @@
#include "config.h"
#include <algorithm>
#include <QtGlobal>
#include <QObject>
#include <QVariant>
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QTimer>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QScopeGuard>
#include "includes/shared_ptr.h"
#include "core/networkaccessmanager.h"
@@ -62,17 +58,6 @@ MusicbrainzCoverProvider::MusicbrainzCoverProvider(const SharedPtr<NetworkAccess
}
MusicbrainzCoverProvider::~MusicbrainzCoverProvider() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
bool MusicbrainzCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
Q_UNUSED(title);
@@ -92,19 +77,12 @@ bool MusicbrainzCoverProvider::StartSearch(const QString &artist, const QString
void MusicbrainzCoverProvider::SendSearchRequest(const SearchRequest &request) {
QString query = QStringLiteral("release:\"%1\" AND artist:\"%2\"").arg(request.album.trimmed().replace(u'"', "\""_L1), request.artist.trimmed().replace(u'"', "\""_L1));
const QString query = QStringLiteral("release:\"%1\" AND artist:\"%2\"").arg(request.album.trimmed().replace(u'"', "\""_L1), request.artist.trimmed().replace(u'"', "\""_L1));
QUrlQuery url_query;
url_query.addQueryItem(u"query"_s, query);
url_query.addQueryItem(u"limit"_s, QString::number(kLimit));
url_query.addQueryItem(u"fmt"_s, u"json"_s);
QUrl url(QString::fromLatin1(kReleaseSearchUrl));
url.setQuery(url_query);
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(req);
replies_ << reply;
QNetworkReply *reply = CreateGetRequest(QUrl(QLatin1String(kReleaseSearchUrl)), url_query);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, request]() { HandleSearchReply(reply, request.id); });
}
@@ -120,6 +98,55 @@ void MusicbrainzCoverProvider::FlushRequests() {
}
JsonBaseRequest::JsonObjectResult MusicbrainzCoverProvider::ParseJsonObject(QNetworkReply *reply) {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
}
JsonObjectResult result(ErrorCode::Success);
result.network_error = reply->error();
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid()) {
result.http_status_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
}
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
QJsonParseError json_parse_error;
const QJsonDocument json_document = QJsonDocument::fromJson(data, &json_parse_error);
if (json_parse_error.error == QJsonParseError::NoError) {
const QJsonObject json_object = json_document.object();
if (json_object.contains("error"_L1) && json_object.contains("help"_L1)) {
const QString error = json_object["error"_L1].toString();
const QString help = json_object["help"_L1].toString();
result.error_code = ErrorCode::APIError;
result.error_message = QStringLiteral("%1 (%2)").arg(error, help);
}
else {
result.json_object = json_document.object();
}
}
else {
result.error_code = ErrorCode::ParseError;
result.error_message = json_parse_error.errorString();
}
}
if (result.error_code != ErrorCode::APIError) {
if (reply->error() != QNetworkReply::NoError) {
result.error_code = ErrorCode::NetworkError;
result.error_message = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else if (result.http_status_code != 200) {
result.error_code = ErrorCode::HttpError;
result.error_message = QStringLiteral("Received HTTP code %1").arg(result.http_status_code);
}
}
return result;
}
void MusicbrainzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int search_id) {
if (!replies_.contains(reply)) return;
@@ -128,41 +155,33 @@ void MusicbrainzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
reply->deleteLater();
CoverProviderSearchResults results;
const QScopeGuard search_finished = qScopeGuard([this, search_id, &results]() { Q_EMIT SearchFinished(search_id, results); });
QByteArray data = GetReplyData(reply);
if (data.isEmpty()) {
Q_EMIT SearchFinished(search_id, results);
const JsonObjectResult json_object_result = ParseJsonObject(reply);
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
QJsonObject json_obj = ExtractJsonObj(data);
if (json_obj.isEmpty()) {
Q_EMIT SearchFinished(search_id, results);
const QJsonObject &json_object = json_object_result.json_object;
if (json_object.isEmpty()) {
return;
}
if (!json_obj.contains("releases"_L1)) {
if (json_obj.contains("error"_L1)) {
QString error = json_obj["error"_L1].toString();
Error(error);
}
else {
Error(u"Json reply is missing releases."_s, json_obj);
}
Q_EMIT SearchFinished(search_id, results);
if (!json_object.contains("releases"_L1)) {
Error(u"Json reply is missing releases."_s, json_object);
return;
}
QJsonValue value_releases = json_obj["releases"_L1];
const QJsonValue value_releases = json_object["releases"_L1];
if (!value_releases.isArray()) {
Error(u"Json releases is not an array."_s, value_releases);
Q_EMIT SearchFinished(search_id, results);
return;
}
const QJsonArray array_releases = value_releases.toArray();
if (array_releases.isEmpty()) {
Q_EMIT SearchFinished(search_id, results);
return;
}
@@ -172,18 +191,18 @@ void MusicbrainzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
Error(u"Invalid Json reply, releases array value is not an object."_s);
continue;
}
QJsonObject obj_release = value_release.toObject();
if (!obj_release.contains("id"_L1) || !obj_release.contains("artist-credit"_L1) || !obj_release.contains("title"_L1)) {
Error(u"Invalid Json reply, releases array object is missing id, artist-credit or title."_s, obj_release);
const QJsonObject object_release = value_release.toObject();
if (!object_release.contains("id"_L1) || !object_release.contains("artist-credit"_L1) || !object_release.contains("title"_L1)) {
Error(u"Invalid Json reply, releases array object is missing id, artist-credit or title."_s, object_release);
continue;
}
QJsonValue json_artists = obj_release["artist-credit"_L1];
if (!json_artists.isArray()) {
Error(u"Invalid Json reply, artist-credit is not a array."_s, json_artists);
const QJsonValue value_artists = object_release["artist-credit"_L1];
if (!value_artists.isArray()) {
Error(u"Invalid Json reply, artist-credit is not a array."_s, value_artists);
continue;
}
const QJsonArray array_artists = json_artists.toArray();
const QJsonArray array_artists = value_artists.toArray();
int i = 0;
QString artist;
for (const QJsonValue &value_artist : array_artists) {
@@ -191,18 +210,18 @@ void MusicbrainzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
Error(u"Invalid Json reply, artist is not a object."_s);
continue;
}
QJsonObject obj_artist = value_artist.toObject();
const QJsonObject object_artist = value_artist.toObject();
if (!obj_artist.contains("artist"_L1)) {
Error(u"Invalid Json reply, artist is missing."_s, obj_artist);
if (!object_artist.contains("artist"_L1)) {
Error(u"Invalid Json reply, artist is missing."_s, object_artist);
continue;
}
QJsonValue value_artist2 = obj_artist["artist"_L1];
const QJsonValue value_artist2 = object_artist["artist"_L1];
if (!value_artist2.isObject()) {
Error(u"Invalid Json reply, artist is not an object."_s, value_artist2);
continue;
}
QJsonObject obj_artist2 = value_artist2.toObject();
const QJsonObject obj_artist2 = value_artist2.toObject();
if (!obj_artist2.contains("name"_L1)) {
Error(u"Invalid Json reply, artist is missing name."_s, value_artist2);
@@ -213,59 +232,16 @@ void MusicbrainzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
}
if (i > 1) artist = "Various artists"_L1;
QString id = obj_release["id"_L1].toString();
QString album = obj_release["title"_L1].toString();
const QString id = object_release["id"_L1].toString();
const QString album = object_release["title"_L1].toString();
CoverProviderSearchResult cover_result;
QUrl url(QString::fromLatin1(kAlbumCoverUrl).arg(id));
const QUrl url(QString::fromLatin1(kAlbumCoverUrl).arg(id));
cover_result.artist = artist;
cover_result.album = album;
cover_result.image_url = url;
results.append(cover_result);
}
Q_EMIT SearchFinished(search_id, results);
}
QByteArray MusicbrainzCoverProvider::GetReplyData(QNetworkReply *reply) {
QByteArray data;
if (reply->error() == QNetworkReply::NoError && reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 200) {
data = reply->readAll();
}
else {
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
// This is a network error, there is nothing more to do.
QString failure_reason = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
Error(failure_reason);
}
else {
// See if there is Json data containing "error" - then use that instead.
data = reply->readAll();
QString error;
QJsonParseError json_error;
QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_error);
if (json_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
QJsonObject json_obj = json_doc.object();
if (json_obj.contains("error"_L1)) {
error = json_obj["error"_L1].toString();
}
}
if (error.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) {
error = QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error());
}
else {
error = QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt());
}
}
Error(error);
}
return QByteArray();
}
return data;
}

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* 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
@@ -22,13 +22,9 @@
#include "config.h"
#include <QObject>
#include <QList>
#include <QQueue>
#include <QByteArray>
#include <QVariant>
#include <QString>
#include <QJsonObject>
#include "includes/shared_ptr.h"
#include "jsoncoverprovider.h"
@@ -42,7 +38,6 @@ class MusicbrainzCoverProvider : public JsonCoverProvider {
public:
explicit MusicbrainzCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
~MusicbrainzCoverProvider() override;
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
@@ -59,13 +54,12 @@ class MusicbrainzCoverProvider : public JsonCoverProvider {
};
void SendSearchRequest(const SearchRequest &request);
QByteArray GetReplyData(QNetworkReply *reply);
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
void Error(const QString &error, const QVariant &debug = QVariant()) override;
private:
QTimer *timer_flush_requests_;
QQueue<SearchRequest> queue_search_requests_;
QList<QNetworkReply*> replies_;
};
#endif // MUSICBRAINZCOVERPROVIDER_H

View File

@@ -1,6 +1,6 @@
/*
* Strawberry Music Player
* Copyright 2020-2021, Jonas Kvinge <jonas@jkvinge.net>
* Copyright 2020-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
@@ -19,20 +19,20 @@
#include "config.h"
#include <QObject>
#include <QByteArray>
#include <QVariant>
#include <QString>
#include <QUrl>
#include <QUrlQuery>
#include <QRegularExpression>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QJsonObject>
#include <QScopeGuard>
#include "includes/shared_ptr.h"
#include "utilities/musixmatchprovider.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "albumcoverfetcher.h"
@@ -40,37 +40,23 @@
#include "musixmatchcoverprovider.h"
using namespace Qt::Literals::StringLiterals;
using namespace MusixmatchProvider;
MusixmatchCoverProvider::MusixmatchCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent)
: JsonCoverProvider(u"Musixmatch"_s, true, false, 1.0, true, false, network, parent) {}
MusixmatchCoverProvider::~MusixmatchCoverProvider() {
while (!replies_.isEmpty()) {
QNetworkReply *reply = replies_.takeFirst();
QObject::disconnect(reply, nullptr, this, nullptr);
reply->abort();
reply->deleteLater();
}
}
bool MusixmatchCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
Q_UNUSED(title);
if (artist.isEmpty() || album.isEmpty()) return false;
QString artist_stripped = StringFixup(artist);
QString album_stripped = StringFixup(album);
const QString artist_stripped = StringFixup(artist);
const QString album_stripped = StringFixup(album);
if (artist_stripped.isEmpty() || album_stripped.isEmpty()) return false;
QUrl url(QStringLiteral("https://www.musixmatch.com/album/%1/%2").arg(artist_stripped, album_stripped));
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
QNetworkReply *reply = network_->get(req);
replies_ << reply;
QNetworkReply *reply = CreateGetRequest(QUrl(QStringLiteral("https://www.musixmatch.com/album/%1/%2").arg(artist_stripped, album_stripped)));
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, id, artist, album]() { HandleSearchReply(reply, id, artist, album); });
//qLog(Debug) << "Musixmatch: Sending request for" << artist_stripped << album_stripped << url;
@@ -89,29 +75,19 @@ void MusixmatchCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
reply->deleteLater();
CoverProviderSearchResults results;
const QScopeGuard search_finished = qScopeGuard([this, id, &results]() { Q_EMIT SearchFinished(id, results);; });
if (reply->error() != QNetworkReply::NoError) {
Error(QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
Q_EMIT SearchFinished(id, results);
return;
}
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) {
Error(QStringLiteral("Received HTTP code %1").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
Q_EMIT SearchFinished(id, results);
const ReplyDataResult reply_data_result = GetReplyData(reply);
if (!reply_data_result.success()) {
Error(reply_data_result.error_message);
return;
}
const QByteArray data = reply->readAll();
if (data.isEmpty()) {
Error(u"Empty reply received from server."_s);
Q_EMIT SearchFinished(id, results);
return;
}
const QByteArray &data = reply_data_result.data;
const QString content = QString::fromUtf8(data);
const QString data_begin = "<script id=\"__NEXT_DATA__\" type=\"application/json\">"_L1;
const QString data_end = "</script>"_L1;
if (!content.contains(data_begin) || !content.contains(data_end)) {
Q_EMIT SearchFinished(id, results);
return;
}
qint64 begin_idx = content.indexOf(data_begin);
@@ -125,89 +101,61 @@ void MusixmatchCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
}
if (content_json.isEmpty()) {
Q_EMIT SearchFinished(id, results);
return;
}
static const QRegularExpression regex_html_tag(u"<[^>]*>"_s);
if (content_json.contains(regex_html_tag)) { // Make sure it's not HTML code.
Q_EMIT SearchFinished(id, results);
return;
}
QJsonParseError error;
QJsonDocument json_doc = QJsonDocument::fromJson(content_json.toUtf8(), &error);
if (error.error != QJsonParseError::NoError) {
Error(QStringLiteral("Failed to parse json data: %1").arg(error.errorString()));
Q_EMIT SearchFinished(id, results);
const JsonObjectResult json_object_result = GetJsonObject(content_json.toUtf8());
if (!json_object_result.success()) {
Error(json_object_result.error_message);
return;
}
if (json_doc.isEmpty()) {
Error(u"Received empty Json document."_s, data);
Q_EMIT SearchFinished(id, results);
QJsonObject json_object = json_object_result.json_object;
if (!json_object.contains("props"_L1) || !json_object["props"_L1].isObject()) {
Error(u"Json reply is missing props."_s, json_object);
return;
}
json_object = json_object["props"_L1].toObject();
if (!json_doc.isObject()) {
Error(u"Json document is not an object."_s, json_doc);
if (!json_object.contains("pageProps"_L1) || !json_object["pageProps"_L1].isObject()) {
Error(u"Json props is missing pageProps."_s, json_object);
Q_EMIT SearchFinished(id, results);
return;
}
json_object = json_object["pageProps"_L1].toObject();
QJsonObject obj_data = json_doc.object();
if (obj_data.isEmpty()) {
Error(u"Received empty Json object."_s, json_doc);
Q_EMIT SearchFinished(id, results);
if (!json_object.contains("data"_L1) || !json_object["data"_L1].isObject()) {
Error(u"Json pageProps is missing data."_s, json_object);
return;
}
json_object = json_object["data"_L1].toObject();
if (!obj_data.contains("props"_L1) || !obj_data["props"_L1].isObject()) {
Error(u"Json reply is missing props."_s, obj_data);
Q_EMIT SearchFinished(id, results);
if (!json_object.contains("albumGet"_L1) || !json_object["albumGet"_L1].isObject()) {
Error(u"Json data is missing albumGet."_s, json_object);
return;
}
obj_data = obj_data["props"_L1].toObject();
json_object = json_object["albumGet"_L1].toObject();
if (!obj_data.contains("pageProps"_L1) || !obj_data["pageProps"_L1].isObject()) {
Error(u"Json props is missing pageProps."_s, obj_data);
Q_EMIT SearchFinished(id, results);
if (!json_object.contains("data"_L1) || !json_object["data"_L1].isObject()) {
Error(u"Json albumGet reply is missing data."_s, json_object);
return;
}
obj_data = obj_data["pageProps"_L1].toObject();
if (!obj_data.contains("data"_L1) || !obj_data["data"_L1].isObject()) {
Error(u"Json pageProps is missing data."_s, obj_data);
Q_EMIT SearchFinished(id, results);
return;
}
obj_data = obj_data["data"_L1].toObject();
if (!obj_data.contains("albumGet"_L1) || !obj_data["albumGet"_L1].isObject()) {
Error(u"Json data is missing albumGet."_s, obj_data);
Q_EMIT SearchFinished(id, results);
return;
}
obj_data = obj_data["albumGet"_L1].toObject();
if (!obj_data.contains("data"_L1) || !obj_data["data"_L1].isObject()) {
Error(u"Json albumGet reply is missing data."_s, obj_data);
Q_EMIT SearchFinished(id, results);
return;
}
obj_data = obj_data["data"_L1].toObject();
json_object = json_object["data"_L1].toObject();
CoverProviderSearchResult result;
if (obj_data.contains("artistName"_L1) && obj_data["artistName"_L1].isString()) {
result.artist = obj_data["artistName"_L1].toString();
if (json_object.contains("artistName"_L1) && json_object["artistName"_L1].isString()) {
result.artist = json_object["artistName"_L1].toString();
}
if (obj_data.contains("name"_L1) && obj_data["name"_L1].isString()) {
result.album = obj_data["name"_L1].toString();
if (json_object.contains("name"_L1) && json_object["name"_L1].isString()) {
result.album = json_object["name"_L1].toString();
}
if (result.artist.compare(artist, Qt::CaseInsensitive) != 0 && result.album.compare(album, Qt::CaseInsensitive) != 0) {
Q_EMIT SearchFinished(id, results);
return;
}
@@ -216,8 +164,8 @@ void MusixmatchCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
<< qMakePair(u"coverImage350x350"_s, QSize(350, 350));
for (const QPair<QString, QSize> &cover_size : cover_sizes) {
if (!obj_data.contains(cover_size.first)) continue;
QUrl cover_url(obj_data[cover_size.first].toString());
if (!json_object.contains(cover_size.first)) continue;
const QUrl cover_url(json_object[cover_size.first].toString());
if (cover_url.isValid()) {
result.image_url = cover_url;
result.image_size = cover_size.second;
@@ -225,8 +173,6 @@ void MusixmatchCoverProvider::HandleSearchReply(QNetworkReply *reply, const int
}
}
Q_EMIT SearchFinished(id, results);
}
void MusixmatchCoverProvider::Error(const QString &error, const QVariant &debug) {

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