Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
306709f498 | ||
|
|
21bdf88d09 | ||
|
|
ff032c3cd7 | ||
|
|
c083110051 | ||
|
|
a7dbeb5d76 | ||
|
|
634f6ea9f5 | ||
|
|
f9e4f9a09a | ||
|
|
aab9889174 | ||
|
|
3b560e4e4f | ||
|
|
9e327c9556 | ||
|
|
1ec640e088 | ||
|
|
463aaf6942 | ||
|
|
71287dd77e | ||
|
|
b66c0f5573 | ||
|
|
a9f2c384fa | ||
|
|
ae9584c213 | ||
|
|
4db1c5ceb8 | ||
|
|
1738259467 | ||
|
|
fcee02edc1 | ||
|
|
4fff5820c5 | ||
|
|
9aa6da2faf | ||
|
|
279934411c | ||
|
|
fd829551e8 | ||
|
|
be8f515388 | ||
|
|
dd12462fbb | ||
|
|
5c3ded0099 | ||
|
|
2c9b14f5f2 | ||
|
|
8c195382c4 | ||
|
|
6e5198799c | ||
|
|
389d04bbec | ||
|
|
6f0f88dd01 | ||
|
|
3dce84c73c | ||
|
|
e798349aca | ||
|
|
41ecf8e535 | ||
|
|
70c96ded28 | ||
|
|
d9cc6bf238 | ||
|
|
0a70ac1c95 | ||
|
|
e3a3f44513 | ||
|
|
c2e42ebf3a | ||
|
|
d71b344e77 | ||
|
|
d5f7a4b883 | ||
|
|
34fb289e33 | ||
|
|
6d4d8251ad | ||
|
|
fc69ef27e8 | ||
|
|
bbd8a24b75 | ||
|
|
9fa9012c70 | ||
|
|
2a4fc185ac | ||
|
|
fa0703246b | ||
|
|
954c21e21e | ||
|
|
8954dfb0aa | ||
|
|
5e031be42c | ||
|
|
d5281abb22 | ||
|
|
f5368d8108 | ||
|
|
e2dc22c2c8 | ||
|
|
817ca828e6 | ||
|
|
61dc2cc640 | ||
|
|
cd4adf6f89 | ||
|
|
91ebcc948e | ||
|
|
e7e6d59172 | ||
|
|
95b12a01a4 | ||
|
|
5947aeae24 | ||
|
|
0298fa0b73 | ||
|
|
05d72c8bd6 | ||
|
|
70b7c4560d | ||
|
|
2687dc31cc | ||
|
|
cabf1cb78d | ||
|
|
d9f68ab944 | ||
|
|
8630c5329d | ||
|
|
66e175f6d1 | ||
|
|
1173d5f865 | ||
|
|
b02b114caf | ||
|
|
cd516c37b9 | ||
|
|
7de8a44709 | ||
|
|
bb345b14de | ||
|
|
baa82966d8 | ||
|
|
f85d60f5cd | ||
|
|
6f731fcf4a | ||
|
|
bdbe66b116 | ||
|
|
5ae0320911 | ||
|
|
31671af5f5 | ||
|
|
27184eb001 | ||
|
|
2e30f5c585 | ||
|
|
109d3f9ec3 | ||
|
|
e9d413c7dc | ||
|
|
ee60191b6c | ||
|
|
a6d8627129 | ||
|
|
d317c9158b | ||
|
|
3716e8c3ef | ||
|
|
ce0e1e900e | ||
|
|
f1aa92ec9f | ||
|
|
25bdfcdb76 | ||
|
|
6a3de3937a | ||
|
|
5f775e87ae | ||
|
|
1fd83c55ee | ||
|
|
e6e9edef7d | ||
|
|
1bae665f76 | ||
|
|
5bfc8b74f5 | ||
|
|
e588896729 | ||
|
|
0cd0f7f2e7 | ||
|
|
6db540a3a7 | ||
|
|
82679e0cea | ||
|
|
d571bc3305 | ||
|
|
2b52553864 | ||
|
|
215627b0e4 | ||
|
|
b17cae6ec7 | ||
|
|
30ac9697ea | ||
|
|
61e3ea249d | ||
|
|
d1986eeae2 | ||
|
|
ba354207d2 | ||
|
|
eac5674891 | ||
|
|
8349a8b0ee | ||
|
|
b9b4e9f831 | ||
|
|
98ff2525f0 |
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
118
.github/workflows/build.yml
vendored
118
.github/workflows/build.yml
vendored
@@ -80,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:
|
||||
@@ -193,6 +206,8 @@ jobs:
|
||||
kdsingleapplication-qt6-devel
|
||||
gtest-devel
|
||||
gmock-devel
|
||||
sparsehash-devel
|
||||
rapidjson-devel
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -229,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
|
||||
@@ -244,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
|
||||
@@ -277,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
|
||||
@@ -284,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
|
||||
@@ -332,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
|
||||
@@ -379,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:
|
||||
@@ -466,12 +491,21 @@ jobs:
|
||||
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:
|
||||
@@ -504,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:
|
||||
@@ -549,12 +583,21 @@ jobs:
|
||||
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:
|
||||
@@ -588,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:
|
||||
@@ -631,6 +674,7 @@ jobs:
|
||||
libmtp-dev
|
||||
libgpod-dev
|
||||
libxkbcommon-dev
|
||||
libsparsehash-dev
|
||||
qt6-base-dev
|
||||
qt6-base-private-dev
|
||||
qt6-base-dev-tools
|
||||
@@ -639,6 +683,8 @@ jobs:
|
||||
qt6-l10n-tools
|
||||
gstreamer1.0-alsa
|
||||
gstreamer1.0-pulseaudio
|
||||
libkdsingleapplication-qt6-dev
|
||||
rapidjson-dev
|
||||
- name: Install keyboxd
|
||||
if: matrix.ubuntu_version == 'noble'
|
||||
env:
|
||||
@@ -651,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
|
||||
@@ -683,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}
|
||||
@@ -708,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
|
||||
|
||||
|
||||
@@ -729,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 }}
|
||||
@@ -738,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
|
||||
@@ -775,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 }}
|
||||
@@ -837,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
|
||||
|
||||
@@ -896,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
4
.gitmodules
vendored
@@ -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
11
3rdparty/README.md
vendored
@@ -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/
|
||||
42
3rdparty/discord-rpc/CMakeLists.txt
vendored
Normal file
42
3rdparty/discord-rpc/CMakeLists.txt
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
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_link_libraries(discord-rpc PRIVATE Qt${QT_VERSION_MAJOR}::Core)
|
||||
|
||||
target_include_directories(discord-rpc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
19
3rdparty/discord-rpc/LICENSE
vendored
Normal file
19
3rdparty/discord-rpc/LICENSE
vendored
Normal 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
162
3rdparty/discord-rpc/README.md
vendored
Normal 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 | [](https://travis-ci.org/discordapp/discord-rpc) |
|
||||
| AppVeyor | [](https://ci.appveyor.com/project/crmarsh/discord-rpc) |
|
||||
| Buildkite (internal) | [](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
63
3rdparty/discord-rpc/discord_backoff.h
vendored
Normal 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
|
||||
48
3rdparty/discord-rpc/discord_connection.h
vendored
Normal file
48
3rdparty/discord-rpc/discord_connection.h
vendored
Normal 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
|
||||
160
3rdparty/discord-rpc/discord_connection_unix.cpp
vendored
Normal file
160
3rdparty/discord-rpc/discord_connection_unix.cpp
vendored
Normal 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
|
||||
160
3rdparty/discord-rpc/discord_connection_win.cpp
vendored
Normal file
160
3rdparty/discord-rpc/discord_connection_win.cpp
vendored
Normal 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
|
||||
64
3rdparty/discord-rpc/discord_msg_queue.h
vendored
Normal file
64
3rdparty/discord-rpc/discord_msg_queue.h
vendored
Normal 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
|
||||
39
3rdparty/discord-rpc/discord_register.h
vendored
Normal file
39
3rdparty/discord-rpc/discord_register.h
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
#include <QString>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void Discord_Register(const QString &applicationId, const char *command);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // DISCORD_REGISTER_H
|
||||
122
3rdparty/discord-rpc/discord_register_linux.cpp
vendored
Normal file
122
3rdparty/discord-rpc/discord_register_linux.cpp
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* 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 <cstdio>
|
||||
#include <errno.h>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "discord_rpc.h"
|
||||
#include "discord_register.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 QString &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.toUtf8().constData(), command, applicationId.toUtf8().constData());
|
||||
if (fileLen <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
char desktopFilename[256]{};
|
||||
(void)snprintf(desktopFilename, sizeof(desktopFilename), "/discord-%s.desktop", applicationId.toUtf8().constData());
|
||||
|
||||
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.toUtf8().constData(),
|
||||
applicationId.toUtf8().constData());
|
||||
if (system(xdgMimeCommand) < 0) {
|
||||
fprintf(stderr, "Failed to register mime handler\n");
|
||||
}
|
||||
|
||||
}
|
||||
101
3rdparty/discord-rpc/discord_register_osx.m
vendored
Normal file
101
3rdparty/discord-rpc/discord_register_osx.m
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 QString &applicationId, const char *command) {
|
||||
|
||||
const QByteArray applicationIdData = applicationId.toUtf8();
|
||||
|
||||
if (command) {
|
||||
RegisterCommand(applicationIdData.constData(), command);
|
||||
}
|
||||
else {
|
||||
// raii lite
|
||||
@autoreleasepool {
|
||||
RegisterURL(applicationIdData.constData());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
166
3rdparty/discord-rpc/discord_register_win.cpp
vendored
Normal file
166
3rdparty/discord-rpc/discord_register_win.cpp
vendored
Normal 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 QString &applicationId, const char *command) {
|
||||
|
||||
wchar_t appId[32]{};
|
||||
MultiByteToWideChar(CP_UTF8, 0, applicationId.toUtf8().constData(), -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);
|
||||
|
||||
}
|
||||
|
||||
513
3rdparty/discord-rpc/discord_rpc.cpp
vendored
Normal file
513
3rdparty/discord-rpc/discord_rpc.cpp
vendored
Normal file
@@ -0,0 +1,513 @@
|
||||
/*
|
||||
* 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 <QString>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#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 Qt::Literals::StringLiterals;
|
||||
|
||||
namespace discord_rpc {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class User {
|
||||
public:
|
||||
explicit User() {}
|
||||
// snowflake (64bit int), turned into a ascii decimal string, at most 20 chars +1 null
|
||||
// terminator = 21
|
||||
QString userId;
|
||||
// 32 unicode glyphs is max name size => 4 bytes per glyph in the worst case, +1 for null
|
||||
// terminator = 129
|
||||
QString username;
|
||||
// 4 decimal digits + 1 null terminator = 5
|
||||
QString discriminator;
|
||||
// optional 'a_' + md5 hex digest (32 bytes) + null terminator = 35
|
||||
QString avatar;
|
||||
// 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 QString JoinGameSecret;
|
||||
static QString SpectateGameSecret;
|
||||
static int LastErrorCode { 0 };
|
||||
static QString LastErrorMessage;
|
||||
static int LastDisconnectErrorCode { 0 };
|
||||
static QString LastDisconnectErrorMessage;
|
||||
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 };
|
||||
|
||||
static void Discord_UpdateConnection(void);
|
||||
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 Discord_UpdateConnection() {
|
||||
|
||||
if (!Connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Connection->IsOpen()) {
|
||||
if (std::chrono::system_clock::now() >= NextConnect) {
|
||||
UpdateReconnectTime();
|
||||
Connection->Open();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// reads
|
||||
|
||||
for (;;) {
|
||||
QJsonDocument json_document;
|
||||
if (!Connection->Read(json_document)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const QJsonObject json_object = json_document.object();
|
||||
const QString event_name = json_object["evt"_L1].toString();
|
||||
const QString nonce = json_object["nonce"_L1].toString();
|
||||
|
||||
if (json_object.contains("nonce"_L1)) {
|
||||
// in responses only -- should use to match up response when needed.
|
||||
|
||||
if (event_name == "ERROR"_L1) {
|
||||
const QJsonObject data = json_object["data"_L1].toObject();
|
||||
LastErrorCode = data["code"_L1].toInt();
|
||||
LastErrorMessage = data["message"_L1].toString();
|
||||
GotErrorMessage.store(true);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// should have evt == name of event, optional data
|
||||
if (event_name.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const QJsonObject data = json_object["data"_L1].toObject();
|
||||
|
||||
if (event_name == "ACTIVITY_JOIN"_L1) {
|
||||
if (data.contains("secret"_L1)) {
|
||||
JoinGameSecret = data["secret"_L1].toString();
|
||||
WasJoinGame.store(true);
|
||||
}
|
||||
}
|
||||
else if (event_name == "ACTIVITY_SPECTATE"_L1) {
|
||||
if (data.contains("secret"_L1)) {
|
||||
SpectateGameSecret = data["secret"_L1].toString();
|
||||
WasSpectateGame.store(true);
|
||||
}
|
||||
}
|
||||
else if (event_name == "ACTIVITY_JOIN_REQUEST"_L1) {
|
||||
const QJsonObject user = data["user"_L1].toObject();
|
||||
const QString userId = user["id"_L1].toString();
|
||||
const QString username = user["username"_L1].toString();
|
||||
const QString avatar = user["avatar"_L1].toString();
|
||||
const auto joinReq = JoinAskQueue.GetNextAddMessage();
|
||||
if (!userId.isEmpty() && !username.isEmpty() && joinReq) {
|
||||
joinReq->userId = userId;
|
||||
joinReq->username = username;
|
||||
const QString discriminator = user["discriminator"_L1].toString();
|
||||
if (!discriminator.isEmpty()) {
|
||||
joinReq->discriminator = discriminator;
|
||||
}
|
||||
if (!avatar.isEmpty()) {
|
||||
joinReq->avatar = avatar;
|
||||
}
|
||||
else {
|
||||
joinReq->avatar.clear();
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
extern "C" void Discord_Initialize(const QString &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 = [](QJsonDocument &readyMessage) {
|
||||
Discord_UpdateHandlers(&QueuedHandlers);
|
||||
if (QueuedPresence.length > 0) {
|
||||
UpdatePresence.exchange(true);
|
||||
SignalIOActivity();
|
||||
}
|
||||
const QJsonValue json_object = readyMessage.object();
|
||||
auto data = json_object["data"_L1].toObject();
|
||||
auto user = data["user"_L1].toObject();
|
||||
auto userId = user["id"_L1].toString();
|
||||
auto username = user["username"_L1].toString();
|
||||
auto avatar = user["avatar"_L1].toString();
|
||||
if (!userId.isEmpty() && !username.isEmpty()) {
|
||||
connectedUser.userId = userId;
|
||||
connectedUser.username = username;
|
||||
const QString discriminator = user["discriminator"_L1].toString();
|
||||
if (!discriminator.isEmpty()) {
|
||||
connectedUser.discriminator = discriminator;
|
||||
}
|
||||
if (!avatar.isEmpty()) {
|
||||
connectedUser.avatar = avatar;
|
||||
}
|
||||
else {
|
||||
connectedUser = User();
|
||||
}
|
||||
}
|
||||
WasJustConnected.exchange(true);
|
||||
ReconnectTimeMs.reset();
|
||||
};
|
||||
Connection->onDisconnect = [](int err, QString &message) {
|
||||
LastDisconnectErrorCode = err;
|
||||
LastDisconnectErrorMessage = message;
|
||||
WasJustDisconnected.exchange(true);
|
||||
UpdateReconnectTime();
|
||||
};
|
||||
|
||||
IoThread->Start();
|
||||
|
||||
}
|
||||
|
||||
extern "C" void Discord_Shutdown(void) {
|
||||
|
||||
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() {
|
||||
Discord_UpdatePresence();
|
||||
}
|
||||
|
||||
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 = {};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} // namespace discord_rpc
|
||||
99
3rdparty/discord-rpc/discord_rpc.h
vendored
Normal file
99
3rdparty/discord-rpc/discord_rpc.h
vendored
Normal 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.
|
||||
*
|
||||
*/
|
||||
|
||||
#ifndef DISCORD_RPC_H
|
||||
#define DISCORD_RPC_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <QString>
|
||||
|
||||
namespace discord_rpc {
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
class DiscordRichPresence {
|
||||
public:
|
||||
int type;
|
||||
QString name; /* max 128 bytes */
|
||||
QString state; /* max 128 bytes */
|
||||
QString details; /* max 128 bytes */
|
||||
qint64 startTimestamp;
|
||||
qint64 endTimestamp;
|
||||
QString largeImageKey; /* max 32 bytes */
|
||||
QString largeImageText; /* max 128 bytes */
|
||||
QString smallImageKey; /* max 32 bytes */
|
||||
QString smallImageText; /* max 128 bytes */
|
||||
QString partyId; /* max 128 bytes */
|
||||
int partySize;
|
||||
int partyMax;
|
||||
int partyPrivacy;
|
||||
QString matchSecret; /* max 128 bytes */
|
||||
QString joinSecret; /* max 128 bytes */
|
||||
QString spectateSecret; /* max 128 bytes */
|
||||
qint8 instance;
|
||||
};
|
||||
|
||||
typedef struct DiscordUser {
|
||||
const QString userId;
|
||||
const QString username;
|
||||
const QString discriminator;
|
||||
const QString avatar;
|
||||
} DiscordUser;
|
||||
|
||||
typedef struct DiscordEventHandlers {
|
||||
void (*ready)(const DiscordUser *request);
|
||||
void (*disconnected)(int errorCode, const QString &message);
|
||||
void (*errored)(int errorCode, const QString &message);
|
||||
void (*joinGame)(const QString &joinSecret);
|
||||
void (*spectateGame)(const QString &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 QString &applicationId, DiscordEventHandlers *handlers, const int autoRegister);
|
||||
void Discord_Shutdown();
|
||||
|
||||
// checks for incoming messages, dispatches callbacks
|
||||
void Discord_RunCallbacks();
|
||||
|
||||
void Discord_UpdatePresence(const DiscordRichPresence &presence = DiscordRichPresence());
|
||||
void Discord_ClearPresence();
|
||||
|
||||
void Discord_Respond(const char *userid, /* DISCORD_REPLY_ */ int reply);
|
||||
|
||||
void Discord_UpdateHandlers(DiscordEventHandlers *handlers);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
#endif
|
||||
|
||||
} // namespace discord_rpc
|
||||
|
||||
#endif // DISCORD_RPC_H
|
||||
175
3rdparty/discord-rpc/discord_rpc_connection.cpp
vendored
Normal file
175
3rdparty/discord-rpc/discord_rpc_connection.cpp
vendored
Normal file
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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 <QString>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include "discord_rpc_connection.h"
|
||||
#include "discord_serialization.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace discord_rpc {
|
||||
|
||||
static const int RpcVersion = 1;
|
||||
static RpcConnection Instance;
|
||||
|
||||
RpcConnection *RpcConnection::Create(const QString &applicationId) {
|
||||
|
||||
Instance.connection = BaseConnection::Create();
|
||||
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) {
|
||||
QJsonDocument json_document;
|
||||
if (Read(json_document)) {
|
||||
const QJsonObject json_object = json_document.object();
|
||||
const QString cmd = json_object["cmd"_L1].toString();
|
||||
const QString evt = json_object["evt"_L1].toString();
|
||||
if (cmd == "DISPATCH"_L1 && evt == "READY"_L1) {
|
||||
state = State::Connected;
|
||||
if (onConnect) {
|
||||
onConnect(json_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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(QJsonDocument &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);
|
||||
lastErrorMessage = "Pipe closed"_L1;
|
||||
Close();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (readFrame.length > 0) {
|
||||
didRead = connection->Read(readFrame.message, readFrame.length);
|
||||
if (!didRead) {
|
||||
lastErrorCode = static_cast<int>(ErrorCode::ReadCorrupt);
|
||||
lastErrorMessage = "Partial data in frame"_L1;
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
readFrame.message[readFrame.length] = 0;
|
||||
}
|
||||
|
||||
switch (readFrame.opcode) {
|
||||
case Opcode::Close: {
|
||||
message = QJsonDocument::fromJson(readFrame.message);
|
||||
lastErrorCode = message["code"_L1].toInt();
|
||||
lastErrorMessage = message["message"_L1].toString();
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
case Opcode::Frame:
|
||||
message = QJsonDocument::fromJson(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);
|
||||
lastErrorMessage = "Bad ipc frame"_L1;
|
||||
Close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} // namespace discord_rpc
|
||||
91
3rdparty/discord-rpc/discord_rpc_connection.h
vendored
Normal file
91
3rdparty/discord-rpc/discord_rpc_connection.h
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 <QString>
|
||||
#include <QJsonDocument>
|
||||
|
||||
#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)(QJsonDocument &message) { nullptr };
|
||||
void (*onDisconnect)(int errorCode, QString &message) { nullptr };
|
||||
QString appId;
|
||||
int lastErrorCode { 0 };
|
||||
QString lastErrorMessage;
|
||||
RpcConnection::MessageFrame sendFrame;
|
||||
|
||||
static RpcConnection *Create(const QString &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(QJsonDocument &message);
|
||||
};
|
||||
|
||||
} // namespace discord_rpc
|
||||
|
||||
#endif // DISCORD_RPC_CONNECTION_H
|
||||
187
3rdparty/discord-rpc/discord_serialization.cpp
vendored
Normal file
187
3rdparty/discord-rpc/discord_serialization.cpp
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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 <QString>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include "discord_serialization.h"
|
||||
#include "discord_rpc.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
void WriteOptionalString(QJsonObject &json_object, const QString &key, const QString &value);
|
||||
void WriteOptionalString(QJsonObject &json_object, const QString &key, const QString &value) {
|
||||
|
||||
if (!value.isEmpty()) {
|
||||
json_object[key] = value;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static QString JsonWriteNonce(const int nonce) {
|
||||
|
||||
char nonce_buffer[32]{};
|
||||
NumberToString(nonce_buffer, nonce);
|
||||
|
||||
return QString::fromLatin1(nonce_buffer);
|
||||
|
||||
}
|
||||
|
||||
size_t JsonWriteRichPresenceObj(char *dest, const size_t maxLen, const int nonce, const int pid, const DiscordRichPresence &presence) {
|
||||
|
||||
QJsonObject json_object;
|
||||
|
||||
json_object["nonce"_L1] = JsonWriteNonce(nonce);
|
||||
json_object["cmd"_L1] = "SET_ACTIVITY"_L1;
|
||||
|
||||
QJsonObject args;
|
||||
args["pid"_L1] = pid;
|
||||
|
||||
QJsonObject activity;
|
||||
|
||||
if (presence.type >= 0 && presence.type <= 5) {
|
||||
activity["type"_L1] = presence.type;
|
||||
}
|
||||
|
||||
activity["state"_L1] = presence.state;
|
||||
activity["details"_L1] = presence.details;
|
||||
|
||||
if (presence.startTimestamp != 0 || presence.endTimestamp != 0) {
|
||||
QJsonObject timestamps;
|
||||
if (presence.startTimestamp != 0) {
|
||||
timestamps["start"_L1] = presence.startTimestamp;
|
||||
}
|
||||
if (presence.endTimestamp != 0) {
|
||||
timestamps["end"_L1] = presence.endTimestamp;
|
||||
}
|
||||
activity["timestamps"_L1] = timestamps;
|
||||
}
|
||||
|
||||
if (!presence.largeImageKey.isEmpty() || !presence.largeImageText.isEmpty() || !presence.smallImageKey.isEmpty() || !presence.smallImageText.isEmpty()) {
|
||||
QJsonObject assets;
|
||||
WriteOptionalString(assets, "large_image"_L1, presence.largeImageKey);
|
||||
WriteOptionalString(assets, "large_text"_L1, presence.largeImageText);
|
||||
WriteOptionalString(assets, "small_image"_L1, presence.smallImageKey);
|
||||
WriteOptionalString(assets, "small_text"_L1, presence.smallImageText);
|
||||
activity["assets"_L1] = assets;
|
||||
}
|
||||
|
||||
activity["instance"_L1] = presence.instance != 0;
|
||||
args["activity"_L1] = activity;
|
||||
json_object["args"_L1] = args;
|
||||
|
||||
QJsonDocument json_document(json_object);
|
||||
QByteArray data = json_document.toJson(QJsonDocument::Compact);
|
||||
strncpy(dest, data.constData(), maxLen);
|
||||
|
||||
return data.length();
|
||||
|
||||
}
|
||||
|
||||
size_t JsonWriteHandshakeObj(char *dest, const size_t maxLen, const int version, const QString &applicationId) {
|
||||
|
||||
QJsonObject json_object;
|
||||
json_object["v"_L1] = version;
|
||||
json_object["client_id"_L1] = applicationId;
|
||||
const QJsonDocument json_document(json_object);
|
||||
const QByteArray data = json_document.toJson(QJsonDocument::Compact);
|
||||
strncpy(dest, data.constData(), maxLen);
|
||||
|
||||
return data.length();
|
||||
|
||||
}
|
||||
|
||||
size_t JsonWriteSubscribeCommand(char *dest, const size_t maxLen, const int nonce, const char *evtName) {
|
||||
|
||||
QJsonObject json_object;
|
||||
json_object["nonce"_L1] = JsonWriteNonce(nonce);
|
||||
json_object["cmd"_L1] = "SUBSCRIBE"_L1;
|
||||
json_object["evt"_L1] = QLatin1String(evtName);
|
||||
const QJsonDocument json_document(json_object);
|
||||
const QByteArray data = json_document.toJson(QJsonDocument::Compact);
|
||||
strncpy(dest, data.constData(), maxLen);
|
||||
|
||||
return data.length();
|
||||
|
||||
}
|
||||
|
||||
size_t JsonWriteUnsubscribeCommand(char *dest, const size_t maxLen, const int nonce, const char *evtName) {
|
||||
|
||||
QJsonObject json_object;
|
||||
json_object["nonce"_L1] = JsonWriteNonce(nonce);
|
||||
json_object["cmd"_L1] = "UNSUBSCRIBE"_L1;
|
||||
json_object["evt"_L1] = QLatin1String(evtName);
|
||||
const QJsonDocument json_document(json_object);
|
||||
const QByteArray data = json_document.toJson(QJsonDocument::Compact);
|
||||
strncpy(dest, data.constData(), maxLen);
|
||||
|
||||
return data.length();
|
||||
|
||||
}
|
||||
|
||||
size_t JsonWriteJoinReply(char *dest, size_t maxLen, const char *userId, const int reply, const int nonce) {
|
||||
|
||||
QJsonObject json_object;
|
||||
json_object["nonce"_L1] = JsonWriteNonce(nonce);
|
||||
json_object["cmd"_L1] = reply == DISCORD_REPLY_YES ? "SEND_ACTIVITY_JOIN_INVITE"_L1 : "CLOSE_ACTIVITY_JOIN_REQUEST"_L1;
|
||||
QJsonObject args;
|
||||
args["user_id"_L1] = QLatin1String(userId);
|
||||
json_object["args"_L1] = args;
|
||||
const QJsonDocument json_document(json_object);
|
||||
const QByteArray data = json_document.toJson(QJsonDocument::Compact);
|
||||
strncpy(dest, data.constData(), maxLen);
|
||||
|
||||
return data.length();
|
||||
|
||||
}
|
||||
|
||||
} // namespace discord_rpc
|
||||
41
3rdparty/discord-rpc/discord_serialization.h
vendored
Normal file
41
3rdparty/discord-rpc/discord_serialization.h
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 <cstddef>
|
||||
#include <QString>
|
||||
|
||||
namespace discord_rpc {
|
||||
|
||||
size_t JsonWriteHandshakeObj(char *dest, size_t maxLen, int version, const QString &applicationId);
|
||||
class DiscordRichPresence;
|
||||
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);
|
||||
|
||||
} // namespace discord_rpc
|
||||
|
||||
#endif // DISCORD_SERIALIZATION_H
|
||||
11
3rdparty/kdsingleapplication/CMakeLists.txt
vendored
11
3rdparty/kdsingleapplication/CMakeLists.txt
vendored
@@ -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)
|
||||
Submodule 3rdparty/kdsingleapplication/KDSingleApplication deleted from cb0c664b40
@@ -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>
|
||||
@@ -210,6 +210,8 @@ endif()
|
||||
|
||||
find_package(GTest)
|
||||
|
||||
pkg_check_modules(LIBSPARSEHASH IMPORTED_TARGET libsparsehash)
|
||||
|
||||
set(QT_VERSION_MAJOR 6)
|
||||
set(QT_MIN_VERSION 6.4.0)
|
||||
set(QT_DEFAULT_MAJOR_VERSION ${QT_VERSION_MAJOR})
|
||||
@@ -247,20 +249,7 @@ if(X11_FOUND)
|
||||
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)
|
||||
@@ -371,6 +360,12 @@ optional_component(QPA_QPLATFORMNATIVEINTERFACE ON "QPA Platform Native Interfac
|
||||
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")
|
||||
|
||||
if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ)
|
||||
set(HAVE_CHROMAPRINT ON)
|
||||
endif()
|
||||
@@ -457,6 +452,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
|
||||
@@ -477,6 +475,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
|
||||
@@ -586,6 +585,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
|
||||
@@ -599,7 +600,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
|
||||
@@ -687,8 +687,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
|
||||
@@ -751,7 +749,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
|
||||
@@ -769,7 +767,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
|
||||
@@ -851,6 +849,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
|
||||
@@ -1233,6 +1234,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
|
||||
@@ -1477,6 +1488,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)
|
||||
@@ -1521,6 +1537,7 @@ target_link_libraries(strawberry_lib PUBLIC
|
||||
$<$<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>
|
||||
@@ -1535,7 +1552,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)
|
||||
@@ -1554,6 +1571,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)
|
||||
|
||||
31
Changelog
31
Changelog
@@ -2,6 +2,37 @@ Strawberry Music Player
|
||||
=======================
|
||||
ChangeLog
|
||||
|
||||
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:
|
||||
|
||||
24
README.md
24
README.md
@@ -4,7 +4,7 @@
|
||||
[](https://patreon.com/jonaskvinge)
|
||||
[](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.
|
||||
|
||||

|
||||
|
||||
@@ -59,7 +59,9 @@ Funding developers is a way to contribute to open source projects you appreciate
|
||||
* 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:
|
||||
|
||||
@@ -95,22 +97,20 @@ Optional dependencies:
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
set(STRAWBERRY_VERSION_MAJOR 1)
|
||||
set(STRAWBERRY_VERSION_MINOR 2)
|
||||
set(STRAWBERRY_VERSION_PATCH 7)
|
||||
set(STRAWBERRY_VERSION_PATCH 9)
|
||||
#set(STRAWBERRY_VERSION_PRERELEASE rc1)
|
||||
|
||||
set(INCLUDE_GIT_REVISION OFF)
|
||||
set(INCLUDE_GIT_REVISION ON)
|
||||
|
||||
set(majorminorpatch "${STRAWBERRY_VERSION_MAJOR}.${STRAWBERRY_VERSION_MINOR}.${STRAWBERRY_VERSION_PATCH}")
|
||||
|
||||
|
||||
5
debian/control
vendored
5
debian/control
vendored
@@ -22,6 +22,7 @@ Build-Depends: debhelper-compat (= 12),
|
||||
qt6-tools-dev,
|
||||
qt6-tools-dev-tools,
|
||||
qt6-l10n-tools,
|
||||
libkdsingleapplication-qt6-dev,
|
||||
libgstreamer1.0-dev,
|
||||
libgstreamer-plugins-base1.0-dev,
|
||||
libcdio-dev,
|
||||
@@ -29,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
|
||||
|
||||
4
debian/rules
vendored
4
debian/rules
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
</screenshots>
|
||||
<update_contact>eclipseo@fedoraproject.org</update_contact>
|
||||
<releases>
|
||||
<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"/>
|
||||
|
||||
8
dist/unix/strawberry.spec.in
vendored
8
dist/unix/strawberry.spec.in
vendored
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
34
dist/windows/strawberry.nsi.in
vendored
34
dist/windows/strawberry.nsi.in
vendored
@@ -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"
|
||||
@@ -719,7 +720,7 @@ Section "Gstreamer plugins" gstreamer-plugins
|
||||
File "/oname=gstwavparse.dll" "gstreamer-plugins\gstwavparse.dll"
|
||||
File "/oname=gstxingmux.dll" "gstreamer-plugins\gstxingmux.dll"
|
||||
!ifdef arch_x64
|
||||
File "/oname=gstspotify.dll" "gstreamer-plugins\gstspotify.dll"
|
||||
;File "/oname=gstspotify.dll" "gstreamer-plugins\gstspotify.dll"
|
||||
!endif
|
||||
!endif ; MSVC
|
||||
|
||||
@@ -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"
|
||||
@@ -1177,7 +1179,7 @@ Section "Uninstall"
|
||||
Delete "$INSTDIR\gstreamer-plugins\gstwavparse.dll"
|
||||
Delete "$INSTDIR\gstreamer-plugins\gstxingmux.dll"
|
||||
!ifdef arch_x64
|
||||
Delete "$INSTDIR\gstreamer-plugins\gstspotify.dll"
|
||||
;Delete "$INSTDIR\gstreamer-plugins\gstspotify.dll"
|
||||
!endif
|
||||
!endif ; msvc
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -208,7 +209,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;
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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_);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
181
src/core/httpbaserequest.cpp
Normal file
181
src/core/httpbaserequest.cpp
Normal 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 ¶ms, 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 ¶m : 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 ¶ms) {
|
||||
|
||||
QUrlQuery url_query;
|
||||
for (const Param ¶m : 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
110
src/core/httpbaserequest.h
Normal 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 ¶ms = 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 ¶ms);
|
||||
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
|
||||
103
src/core/jsonbaserequest.cpp
Normal file
103
src/core/jsonbaserequest.cpp
Normal 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();
|
||||
|
||||
}
|
||||
68
src/core/jsonbaserequest.h
Normal file
68
src/core/jsonbaserequest.h
Normal 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
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,7 +279,13 @@ 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_WIN32
|
||||
@@ -284,6 +294,9 @@ MainWindow::MainWindow(Application *app, SharedPtr<SystemTrayIcon> tray_icon, OS
|
||||
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);
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -2946,7 +2981,7 @@ void MainWindow::OpenSettingsDialog() {
|
||||
|
||||
}
|
||||
|
||||
void MainWindow::OpenSettingsDialogAtPage(SettingsDialog::Page page) {
|
||||
void MainWindow::OpenSettingsDialogAtPage(const SettingsDialog::Page page) {
|
||||
settings_dialog_->OpenAtPage(page);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
@@ -99,11 +102,24 @@ 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);
|
||||
@@ -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);
|
||||
@@ -296,6 +314,9 @@ class MainWindow : public QMainWindow, public PlatformInterface {
|
||||
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_;
|
||||
@@ -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_;
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,4 @@ int MultiSortFilterProxy::Compare(const QVariant &left, const QVariant &right) c
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
607
src/core/oauthenticator.cpp
Normal 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 ¶m : 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 ¶ms, const bool refresh_token) {
|
||||
|
||||
QUrlQuery url_query;
|
||||
for (const Param ¶m : 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
127
src/core/oauthenticator.h
Normal 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 ¶ms, 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ®ex_list) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{}
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ¶m : 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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ¶ms_provided) {
|
||||
QNetworkReply *DiscogsCoverProvider::CreateRequest(const QUrl &url, const ParamList ¶ms) {
|
||||
|
||||
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 ¶m : params) {
|
||||
EncodedParam encoded_param(QUrl::toPercentEncoding(param.first), QUrl::toPercentEncoding(param.second));
|
||||
for (const Param ¶m : 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) {
|
||||
|
||||
@@ -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 ¶ms_provided = ParamList());
|
||||
QByteArray GetReplyData(QNetworkReply *reply);
|
||||
QNetworkReply *CreateRequest(const QUrl &url, const ParamList ¶ms = 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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -22,25 +22,20 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QString>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "jsoncoverprovider.h"
|
||||
#include "providers/musixmatchprovider.h"
|
||||
|
||||
class QNetworkReply;
|
||||
class NetworkAccessManager;
|
||||
|
||||
class MusixmatchCoverProvider : public JsonCoverProvider, MusixmatchProvider {
|
||||
class MusixmatchCoverProvider : public JsonCoverProvider {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MusixmatchCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
|
||||
~MusixmatchCoverProvider() override;
|
||||
|
||||
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
|
||||
void CancelSearch(const int id) override;
|
||||
@@ -50,9 +45,6 @@ class MusixmatchCoverProvider : public JsonCoverProvider, MusixmatchProvider {
|
||||
|
||||
private Q_SLOTS:
|
||||
void HandleSearchReply(QNetworkReply *reply, const int id, const QString &artist, const QString &album);
|
||||
|
||||
private:
|
||||
QList<QNetworkReply*> replies_;
|
||||
};
|
||||
|
||||
#endif // MUSIXMATCHCOVERPROVIDER_H
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2024, Jonas Kvinge <jonas@jkvinge.net>
|
||||
* Copyright 2024-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,11 +19,6 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
@@ -36,13 +31,13 @@
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QTimer>
|
||||
#include <QScopeGuard>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "core/networkaccessmanager.h"
|
||||
#include "core/logging.h"
|
||||
#include "core/settings.h"
|
||||
#include "core/song.h"
|
||||
#include "constants/timeconstants.h"
|
||||
#include "core/oauthenticator.h"
|
||||
#include "albumcoverfetcher.h"
|
||||
#include "jsoncoverprovider.h"
|
||||
#include "opentidalcoverprovider.h"
|
||||
@@ -51,8 +46,8 @@ using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace {
|
||||
constexpr char kSettingsGroup[] = "OpenTidal";
|
||||
constexpr char kAuthUrl[] = "https://auth.tidal.com/v1/oauth2/token";
|
||||
constexpr char kApiUrl[] = "https://openapi.tidal.com";
|
||||
constexpr char kOAuthAccessTokenUrl[] = "https://auth.tidal.com/v1/oauth2/token";
|
||||
constexpr char kApiUrl[] = "https://openapi.tidal.com/v2";
|
||||
constexpr char kApiClientIdB64[] = "RHBwV3FpTEM4ZFJSV1RJaQ==";
|
||||
constexpr char kApiClientSecretB64[] = "cGk0QmxpclZXQWlteWpBc0RnWmZ5RmVlRzA2b3E1blVBVTljUW1IdFhDST0=";
|
||||
constexpr int kLimit = 10;
|
||||
@@ -63,32 +58,24 @@ using std::make_shared;
|
||||
|
||||
OpenTidalCoverProvider::OpenTidalCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent)
|
||||
: JsonCoverProvider(u"OpenTidal"_s, true, false, 2.5, true, false, network, parent),
|
||||
login_timer_(new QTimer(this)),
|
||||
oauth_(new OAuthenticator(network, this)),
|
||||
timer_flush_requests_(new QTimer(this)),
|
||||
login_in_progress_(false),
|
||||
have_login_(false),
|
||||
login_time_(0),
|
||||
expires_in_(0) {
|
||||
login_in_progress_(false) {
|
||||
|
||||
login_timer_->setSingleShot(true);
|
||||
QObject::connect(login_timer_, &QTimer::timeout, this, &OpenTidalCoverProvider::Login);
|
||||
oauth_->set_settings_group(QLatin1String(kSettingsGroup));
|
||||
oauth_->set_type(OAuthenticator::Type::Client_Credentials);
|
||||
oauth_->set_access_token_url(QUrl(QLatin1String(kOAuthAccessTokenUrl)));
|
||||
oauth_->set_client_id(QString::fromLatin1(QByteArray::fromBase64(kApiClientIdB64)));
|
||||
oauth_->set_client_secret(QString::fromLatin1(QByteArray::fromBase64(kApiClientSecretB64)));
|
||||
oauth_->set_use_local_redirect_server(false);
|
||||
oauth_->set_random_port(false);
|
||||
QObject::connect(oauth_, &OAuthenticator::AuthenticationFinished, this, &OpenTidalCoverProvider::OAuthFinished);
|
||||
|
||||
timer_flush_requests_->setInterval(kRequestsDelay);
|
||||
timer_flush_requests_->setSingleShot(false);
|
||||
QObject::connect(timer_flush_requests_, &QTimer::timeout, this, &OpenTidalCoverProvider::FlushRequests);
|
||||
|
||||
LoadSession();
|
||||
|
||||
}
|
||||
|
||||
OpenTidalCoverProvider::~OpenTidalCoverProvider() {
|
||||
|
||||
while (!replies_.isEmpty()) {
|
||||
QNetworkReply *reply = replies_.takeFirst();
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
oauth_->LoadSession();
|
||||
|
||||
}
|
||||
|
||||
@@ -96,7 +83,7 @@ bool OpenTidalCoverProvider::StartSearch(const QString &artist, const QString &a
|
||||
|
||||
if (artist.isEmpty() || album.isEmpty()) return false;
|
||||
|
||||
if (!have_login_ && !login_in_progress_ && QDateTime::currentSecsSinceEpoch() - last_login_attempt_.toSecsSinceEpoch() < 120) {
|
||||
if (!oauth_->authenticated() && !login_in_progress_ && (last_login_attempt_.isValid() && (QDateTime::currentSecsSinceEpoch() - last_login_attempt_.toSecsSinceEpoch()) < 120)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -115,32 +102,10 @@ void OpenTidalCoverProvider::CancelSearch(const int id) {
|
||||
Q_UNUSED(id);
|
||||
}
|
||||
|
||||
void OpenTidalCoverProvider::LoadSession() {
|
||||
|
||||
Settings s;
|
||||
s.beginGroup(kSettingsGroup);
|
||||
token_type_ = s.value("token_type").toString();
|
||||
access_token_ = s.value("access_token").toString();
|
||||
expires_in_ = s.value("expires_in", 0).toLongLong();
|
||||
login_time_ = s.value("login_time", 0).toLongLong();
|
||||
s.endGroup();
|
||||
|
||||
if (!token_type_.isEmpty() && !access_token_.isEmpty() && (login_time_ + expires_in_) > (QDateTime::currentSecsSinceEpoch() + 30)) {
|
||||
have_login_ = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void OpenTidalCoverProvider::FlushRequests() {
|
||||
|
||||
if (have_login_ && (login_time_ + expires_in_) < QDateTime::currentSecsSinceEpoch()) {
|
||||
have_login_ = false;
|
||||
}
|
||||
|
||||
if (!have_login_) {
|
||||
if (!login_in_progress_) {
|
||||
Login();
|
||||
}
|
||||
if (!oauth_->authenticated()) {
|
||||
LoginCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +120,7 @@ void OpenTidalCoverProvider::FlushRequests() {
|
||||
|
||||
void OpenTidalCoverProvider::LoginCheck() {
|
||||
|
||||
if (!login_in_progress_ && (!last_login_attempt_.isValid() || QDateTime::currentSecsSinceEpoch() - last_login_attempt_.toSecsSinceEpoch() > 120)) {
|
||||
if (!oauth_->authenticated() && !login_in_progress_ && (!last_login_attempt_.isValid() || QDateTime::currentSecsSinceEpoch() - last_login_attempt_.toSecsSinceEpoch() > 120)) {
|
||||
Login();
|
||||
}
|
||||
|
||||
@@ -165,156 +130,93 @@ void OpenTidalCoverProvider::Login() {
|
||||
|
||||
qLog(Debug) << "Authenticating...";
|
||||
|
||||
if (login_timer_->isActive()) {
|
||||
login_timer_->stop();
|
||||
}
|
||||
|
||||
have_login_ = false;
|
||||
login_in_progress_ = true;
|
||||
last_login_attempt_ = QDateTime::currentDateTime();
|
||||
|
||||
QUrl url(QString::fromLatin1(kAuthUrl));
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
req.setRawHeader("Authorization", "Basic " + QByteArray(QByteArray::fromBase64(kApiClientIdB64) + ":" + QByteArray::fromBase64(kApiClientSecretB64)).toBase64());
|
||||
QUrlQuery url_query;
|
||||
url_query.addQueryItem(u"grant_type"_s, u"client_credentials"_s);
|
||||
QNetworkReply *reply = network_->post(req, url_query.toString(QUrl::FullyEncoded).toUtf8());
|
||||
replies_ << reply;
|
||||
QObject::connect(reply, &QNetworkReply::sslErrors, this, &OpenTidalCoverProvider::HandleLoginSSLErrors);
|
||||
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { LoginFinished(reply); });
|
||||
oauth_->Authenticate();
|
||||
|
||||
}
|
||||
|
||||
void OpenTidalCoverProvider::HandleLoginSSLErrors(const QList<QSslError> &ssl_errors) {
|
||||
|
||||
for (const QSslError &ssl_error : ssl_errors) {
|
||||
qLog(Error) << "OpenTidal:" << ssl_error.errorString();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void OpenTidalCoverProvider::LoginFinished(QNetworkReply *reply) {
|
||||
|
||||
if (!replies_.contains(reply)) return;
|
||||
replies_.removeAll(reply);
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->deleteLater();
|
||||
void OpenTidalCoverProvider::OAuthFinished(const bool success, const QString &error) {
|
||||
|
||||
login_in_progress_ = false;
|
||||
last_login_attempt_ = QDateTime();
|
||||
|
||||
QJsonObject json_obj = GetJsonObject(reply);
|
||||
if (json_obj.isEmpty()) {
|
||||
FinishAllSearches();
|
||||
return;
|
||||
if (success) {
|
||||
qLog(Debug) << "OpenTidal: Authentication successful";
|
||||
last_login_attempt_ = QDateTime();
|
||||
if (!timer_flush_requests_->isActive()) {
|
||||
timer_flush_requests_->start();
|
||||
}
|
||||
}
|
||||
|
||||
if (!json_obj.contains("access_token"_L1) ||
|
||||
!json_obj.contains("token_type"_L1) ||
|
||||
!json_obj.contains("expires_in"_L1) ||
|
||||
!json_obj["access_token"_L1].isString() ||
|
||||
!json_obj["token_type"_L1].isString()) {
|
||||
qLog(Error) << "OpenTidal: Invalid login reply.";
|
||||
FinishAllSearches();
|
||||
return;
|
||||
else {
|
||||
qLog(Debug) << "OpenTidal: Authentication failed" << error;
|
||||
last_login_attempt_ = QDateTime::currentDateTime();
|
||||
}
|
||||
|
||||
have_login_ = true;
|
||||
token_type_ = json_obj["token_type"_L1].toString();
|
||||
access_token_ = json_obj["access_token"_L1].toString();
|
||||
login_time_ = QDateTime::currentSecsSinceEpoch();
|
||||
expires_in_ = json_obj["expires_in"_L1].toInt();
|
||||
|
||||
Settings s;
|
||||
s.beginGroup(kSettingsGroup);
|
||||
s.setValue("token_type", token_type_);
|
||||
s.setValue("access_token", access_token_);
|
||||
s.setValue("expires_in", expires_in_);
|
||||
s.setValue("login_time", login_time_);
|
||||
s.endGroup();
|
||||
|
||||
if (expires_in_ <= 300) {
|
||||
expires_in_ = 300;
|
||||
}
|
||||
|
||||
expires_in_ -= 30;
|
||||
|
||||
login_timer_->setInterval(static_cast<int>(expires_in_ * kMsecPerSec));
|
||||
login_timer_->start();
|
||||
|
||||
if (!timer_flush_requests_->isActive()) {
|
||||
timer_flush_requests_->start();
|
||||
}
|
||||
|
||||
qLog(Debug) << "Authentication successful";
|
||||
|
||||
}
|
||||
|
||||
QJsonObject OpenTidalCoverProvider::ExtractJsonObj(const QByteArray &data) {
|
||||
JsonBaseRequest::JsonObjectResult OpenTidalCoverProvider::ParseJsonObject(QNetworkReply *reply) {
|
||||
|
||||
QJsonParseError json_parse_error;
|
||||
const QJsonDocument json_doc = QJsonDocument::fromJson(data, &json_parse_error);
|
||||
if (json_parse_error.error != QJsonParseError::NoError) {
|
||||
qLog(Error) << "OpenTidal:" << json_parse_error.errorString();
|
||||
return QJsonObject();
|
||||
if (reply->error() != QNetworkReply::NoError && reply->error() < 200) {
|
||||
return ReplyDataResult(ErrorCode::NetworkError, QStringLiteral("%1 (%2)").arg(reply->errorString()).arg(reply->error()));
|
||||
}
|
||||
if (!json_doc.isObject()) {
|
||||
return QJsonObject();
|
||||
}
|
||||
return json_doc.object();
|
||||
|
||||
}
|
||||
|
||||
QJsonObject OpenTidalCoverProvider::GetJsonObject(QNetworkReply *reply) {
|
||||
|
||||
const int http_code = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (reply->error() != QNetworkReply::NoError || (http_code != 200 && http_code != 207)) {
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
qLog(Error) << "OpenTidal:" << reply->errorString();
|
||||
if (reply->error() < 200) {
|
||||
return QJsonObject();
|
||||
}
|
||||
if (reply->error() == QNetworkReply::AuthenticationRequiredError) {
|
||||
LoginCheck();
|
||||
}
|
||||
}
|
||||
else if (http_code != 200 && http_code != 207) {
|
||||
qLog(Error) << "OpenTidal: Received HTTP code" << http_code;
|
||||
}
|
||||
const QByteArray data = reply->readAll();
|
||||
if (data.isEmpty()) {
|
||||
return QJsonObject();
|
||||
}
|
||||
QJsonObject json_obj = ExtractJsonObj(data);
|
||||
if (json_obj.contains("errors"_L1) && json_obj["errors"_L1].isArray()) {
|
||||
const QJsonArray array = json_obj["errors"_L1].toArray();
|
||||
for (const QJsonValue &value : array) {
|
||||
if (!value.isObject()) continue;
|
||||
QJsonObject obj = value.toObject();
|
||||
if (!obj.contains("category"_L1) ||
|
||||
!obj.contains("code"_L1) ||
|
||||
!obj.contains("detail"_L1)) {
|
||||
continue;
|
||||
}
|
||||
QString category = obj["category"_L1].toString();
|
||||
QString code = obj["code"_L1].toString();
|
||||
QString detail = obj["detail"_L1].toString();
|
||||
qLog(Error) << "OpenTidal:" << category << code << detail;
|
||||
if (category == "AUTHENTICATION_ERROR"_L1) {
|
||||
LoginCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
return QJsonObject();
|
||||
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()) {
|
||||
return QJsonObject();
|
||||
bool clear_session = false;
|
||||
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("errors"_L1) && json_object["errors"_L1].isArray()) {
|
||||
const QJsonArray array_errors = json_object["errors"_L1].toArray();
|
||||
for (const auto &value : array_errors) {
|
||||
if (!value.isObject()) continue;
|
||||
const QJsonObject object_error = value.toObject();
|
||||
if (!object_error.contains("category"_L1) || !object_error.contains("code"_L1) || !object_error.contains("detail"_L1)) {
|
||||
continue;
|
||||
}
|
||||
const QString category = object_error["category"_L1].toString();
|
||||
const QString code = object_error["code"_L1].toString();
|
||||
const QString detail = object_error["detail"_L1].toString();
|
||||
result.error_code = ErrorCode::APIError;
|
||||
result.error_message = QStringLiteral("%1 (%2) (%3)").arg(category, code, detail);
|
||||
if (category == "AUTHENTICATION_ERROR"_L1) {
|
||||
clear_session = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
result.json_object = json_document.object();
|
||||
}
|
||||
}
|
||||
else {
|
||||
result.error_code = ErrorCode::ParseError;
|
||||
result.error_message = json_parse_error.errorString();
|
||||
}
|
||||
}
|
||||
|
||||
return ExtractJsonObj(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);
|
||||
}
|
||||
}
|
||||
|
||||
if (reply->error() == QNetworkReply::AuthenticationRequiredError || clear_session) {
|
||||
oauth_->ClearSession();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
@@ -331,17 +233,19 @@ void OpenTidalCoverProvider::SendSearchRequest(SearchRequestPtr search_request)
|
||||
}
|
||||
|
||||
QUrlQuery url_query;
|
||||
url_query.addQueryItem(u"query"_s, QString::fromUtf8(QUrl::toPercentEncoding(query)));
|
||||
url_query.addQueryItem(u"include"_s, u"albums"_s);
|
||||
url_query.addQueryItem(u"limit"_s, QString::number(kLimit));
|
||||
url_query.addQueryItem(u"countryCode"_s, u"US"_s);
|
||||
QUrl url(QLatin1String(kApiUrl) + "/search"_L1);
|
||||
QUrl url(QLatin1String(kApiUrl) + "/searchresults/"_L1 + QString::fromUtf8(QUrl::toPercentEncoding(query)));
|
||||
url.setQuery(url_query);
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/vnd.tidal.v1+json"_s);
|
||||
req.setRawHeader("Authorization", token_type_.toUtf8() + " " + access_token_.toUtf8());
|
||||
QNetworkRequest network_request(url);
|
||||
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
network_request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/vnd.tidal.v1+json"_s);
|
||||
if (oauth_->authenticated()) {
|
||||
network_request.setRawHeader("Authorization", oauth_->authorization_header());
|
||||
}
|
||||
|
||||
QNetworkReply *reply = network_->get(req);
|
||||
QNetworkReply *reply = network_->get(network_request);
|
||||
replies_ << reply;
|
||||
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, search_request]() { HandleSearchReply(reply, search_request); });
|
||||
|
||||
@@ -354,92 +258,76 @@ void OpenTidalCoverProvider::HandleSearchReply(QNetworkReply *reply, SearchReque
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->deleteLater();
|
||||
|
||||
QJsonObject json_obj = GetJsonObject(reply);
|
||||
if (json_obj.isEmpty()) {
|
||||
CoverProviderSearchResults results;
|
||||
const QScopeGuard search_finished = qScopeGuard([this, search_request, &results]() { Q_EMIT SearchFinished(search_request->id, results); });
|
||||
|
||||
const JsonObjectResult json_object_result = ParseJsonObject(reply);
|
||||
if (!json_object_result.success()) {
|
||||
Error(json_object_result.error_message);
|
||||
if (login_in_progress_) {
|
||||
search_requests_queue_.prepend(search_request);
|
||||
}
|
||||
else {
|
||||
Q_EMIT SearchFinished(search_request->id, CoverProviderSearchResults());
|
||||
}
|
||||
return;
|
||||
}
|
||||
const QJsonObject &json_object = json_object_result.json_object;
|
||||
|
||||
if (!json_object.contains("included"_L1) || !json_object["included"_L1].isArray()) {
|
||||
qLog(Error) << "OpenTidal: Json object is missing included.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json_obj.contains("albums"_L1) || !json_obj["albums"_L1].isArray()) {
|
||||
qLog(Debug) << "OpenTidal: Json object is missing albums.";
|
||||
Q_EMIT SearchFinished(search_request->id, CoverProviderSearchResults());
|
||||
const QJsonArray array_included = json_object["included"_L1].toArray();
|
||||
if (array_included.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray array_albums = json_obj["albums"_L1].toArray();
|
||||
if (array_albums.isEmpty()) {
|
||||
Q_EMIT SearchFinished(search_request->id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
|
||||
CoverProviderSearchResults results;
|
||||
int i = 0;
|
||||
for (const QJsonValue &value_album : array_albums) {
|
||||
for (const auto &value_included : array_included) {
|
||||
|
||||
if (!value_album.isObject()) {
|
||||
qLog(Debug) << "OpenTidal: Invalid Json reply: Albums array value is not a object.";
|
||||
if (!value_included.isObject()) {
|
||||
qLog(Error) << "OpenTidal: Invalid Json reply: Albums array value is not a object.";
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_album = value_album.toObject();
|
||||
const QJsonObject object_included = value_included.toObject();
|
||||
|
||||
if (!obj_album.contains("resource"_L1) || !obj_album["resource"_L1].isObject()) {
|
||||
qLog(Debug) << "OpenTidal: Invalid Json reply: Albums array album is missing resource object.";
|
||||
if (!object_included.contains("attributes"_L1) || !object_included["attributes"_L1].isObject()) {
|
||||
qLog(Error) << "OpenTidal: Invalid Json reply: Included array item is missing attributes object." << object_included;
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_resource = obj_album["resource"_L1].toObject();
|
||||
const QJsonObject object_attributes = object_included["attributes"_L1].toObject();
|
||||
|
||||
if (!obj_resource.contains("artists"_L1) || !obj_resource["artists"_L1].isArray()) {
|
||||
qLog(Debug) << "OpenTidal: Invalid Json reply: Resource is missing artists array.";
|
||||
if (!object_attributes.contains("title"_L1) || !object_attributes["title"_L1].isString()) {
|
||||
qLog(Error) << "OpenTidal: Invalid Json reply: Attributes is missing title string." << object_attributes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!obj_resource.contains("title"_L1) || !obj_resource["title"_L1].isString()) {
|
||||
qLog(Debug) << "OpenTidal: Invalid Json reply: Resource is missing title.";
|
||||
if (!object_attributes.contains("imageLinks"_L1) || !object_attributes["imageLinks"_L1].isArray()) {
|
||||
qLog(Error) << "OpenTidal: Invalid Json reply: Attributes is missing imageLinks object." << object_attributes;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!obj_resource.contains("imageCover"_L1) || !obj_resource["imageCover"_L1].isArray()) {
|
||||
qLog(Debug) << "OpenTidal: Invalid Json reply: Resource is missing imageCover array.";
|
||||
continue;
|
||||
}
|
||||
const QString album = object_attributes["title"_L1].toString();
|
||||
const QJsonArray array_imagelinks = object_attributes["imageLinks"_L1].toArray();
|
||||
|
||||
QString artist;
|
||||
const QString album = obj_resource["title"_L1].toString();
|
||||
|
||||
const QJsonArray array_artists = obj_resource["artists"_L1].toArray();
|
||||
for (const QJsonValue &value_artist : array_artists) {
|
||||
if (!value_artist.isObject()) {
|
||||
for (const auto &value_imagelink : array_imagelinks) {
|
||||
if (!value_imagelink.isObject()) {
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_artist = value_artist.toObject();
|
||||
if (!obj_artist.contains("name"_L1)) {
|
||||
const QJsonObject object_imagelink = value_imagelink.toObject();
|
||||
if (!object_imagelink.contains("href"_L1) || !object_imagelink.contains("meta"_L1) || !object_imagelink["meta"_L1].isObject()) {
|
||||
continue;
|
||||
}
|
||||
artist = obj_artist["name"_L1].toString();
|
||||
break;
|
||||
}
|
||||
|
||||
const QJsonArray array_covers = obj_resource["imageCover"_L1].toArray();
|
||||
for (const QJsonValue &value_cover : array_covers) {
|
||||
if (!value_cover.isObject()) {
|
||||
QJsonObject object_meta = object_imagelink["meta"_L1].toObject();
|
||||
if (!object_meta.contains("width"_L1) || !object_meta.contains("height"_L1)) {
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_cover = value_cover.toObject();
|
||||
if (!obj_cover.contains("url"_L1) || !obj_cover.contains("width"_L1) || !obj_cover.contains("height"_L1)) {
|
||||
continue;
|
||||
}
|
||||
const QUrl url(obj_cover["url"_L1].toString());
|
||||
const int width = obj_cover["width"_L1].toInt();
|
||||
const int height = obj_cover["height"_L1].toInt();
|
||||
const QUrl url(object_imagelink["href"_L1].toString());
|
||||
const int width = object_meta["width"_L1].toInt();
|
||||
const int height = object_meta["height"_L1].toInt();
|
||||
if (!url.isValid()) continue;
|
||||
if (width < 640 || height < 640) continue;
|
||||
CoverProviderSearchResult cover_result;
|
||||
cover_result.artist = artist;
|
||||
cover_result.artist = search_request->artist;
|
||||
cover_result.album = Song::AlbumRemoveDiscMisc(album);
|
||||
cover_result.image_url = url;
|
||||
cover_result.image_size = QSize(width, height);
|
||||
@@ -448,8 +336,6 @@ void OpenTidalCoverProvider::HandleSearchReply(QNetworkReply *reply, SearchReque
|
||||
}
|
||||
}
|
||||
|
||||
Q_EMIT SearchFinished(search_request->id, results);
|
||||
|
||||
}
|
||||
|
||||
void OpenTidalCoverProvider::FinishAllSearches() {
|
||||
@@ -465,7 +351,7 @@ void OpenTidalCoverProvider::FinishAllSearches() {
|
||||
|
||||
void OpenTidalCoverProvider::Error(const QString &error, const QVariant &debug) {
|
||||
|
||||
qLog(Error) << "Tidal:" << error;
|
||||
qLog(Error) << "OpenTidal:" << error;
|
||||
if (debug.isValid()) qLog(Debug) << debug;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2024, Jonas Kvinge <jonas@jkvinge.net>
|
||||
* Copyright 2024-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,15 +22,10 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QQueue>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QDateTime>
|
||||
#include <QSslError>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "jsoncoverprovider.h"
|
||||
@@ -38,13 +33,13 @@
|
||||
class QNetworkReply;
|
||||
class NetworkAccessManager;
|
||||
class QTimer;
|
||||
class OAuthenticator;
|
||||
|
||||
class OpenTidalCoverProvider : public JsonCoverProvider {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit OpenTidalCoverProvider(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
|
||||
~OpenTidalCoverProvider() override;
|
||||
|
||||
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
|
||||
void CancelSearch(const int id) override;
|
||||
@@ -60,33 +55,24 @@ class OpenTidalCoverProvider : public JsonCoverProvider {
|
||||
using SearchRequestPtr = SharedPtr<SearchRequest>;
|
||||
|
||||
private:
|
||||
void LoadSession();
|
||||
void LoginCheck();
|
||||
void Login();
|
||||
QJsonObject GetJsonObject(QNetworkReply *reply);
|
||||
QJsonObject ExtractJsonObj(const QByteArray &data);
|
||||
void SendSearchRequest(SearchRequestPtr request);
|
||||
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
|
||||
void FinishAllSearches();
|
||||
void Error(const QString &error, const QVariant &debug = QVariant()) override;
|
||||
|
||||
private Q_SLOTS:
|
||||
void OAuthFinished(const bool success, const QString &error = QString());
|
||||
void FlushRequests();
|
||||
void LoginFinished(QNetworkReply *reply);
|
||||
void HandleLoginSSLErrors(const QList<QSslError> &ssl_errors);
|
||||
void HandleSearchReply(QNetworkReply *reply, OpenTidalCoverProvider::SearchRequestPtr search_request);
|
||||
|
||||
private:
|
||||
QTimer *login_timer_;
|
||||
OAuthenticator *oauth_;
|
||||
QTimer *timer_flush_requests_;
|
||||
bool login_in_progress_;
|
||||
QDateTime last_login_attempt_;
|
||||
bool have_login_;
|
||||
QString token_type_;
|
||||
QString access_token_;
|
||||
qint64 login_time_;
|
||||
qint64 expires_in_;
|
||||
QQueue<SearchRequestPtr> search_requests_queue_;
|
||||
QList<QNetworkReply*> replies_;
|
||||
};
|
||||
|
||||
#endif // OPENTIDALCOVERPROVIDER_H
|
||||
|
||||
@@ -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
|
||||
@@ -22,8 +22,6 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QObject>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
@@ -35,6 +33,7 @@
|
||||
#include <QJsonValue>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QScopeGuard>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "core/networkaccessmanager.h"
|
||||
@@ -49,26 +48,27 @@ using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace {
|
||||
constexpr int kLimit = 10;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
QobuzCoverProvider::QobuzCoverProvider(const QobuzServicePtr service, SharedPtr<NetworkAccessManager> network, QObject *parent)
|
||||
: JsonCoverProvider(u"Qobuz"_s, true, true, 2.0, true, true, network, parent),
|
||||
service_(service) {}
|
||||
|
||||
QobuzCoverProvider::~QobuzCoverProvider() {
|
||||
bool QobuzCoverProvider::authenticated() const {
|
||||
|
||||
while (!replies_.isEmpty()) {
|
||||
QNetworkReply *reply = replies_.takeFirst();
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
return service_->authenticated();
|
||||
|
||||
}
|
||||
|
||||
void QobuzCoverProvider::ClearSession() {
|
||||
|
||||
service_->ClearSession();
|
||||
|
||||
}
|
||||
|
||||
bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
|
||||
|
||||
if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
|
||||
if (!service_->authenticated() || (artist.isEmpty() && album.isEmpty() && title.isEmpty())) return false;
|
||||
|
||||
QString resource;
|
||||
QString query = artist;
|
||||
@@ -99,12 +99,12 @@ bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album
|
||||
QUrl url(QLatin1String(QobuzService::kApiUrl) + QLatin1Char('/') + resource);
|
||||
url.setQuery(url_query);
|
||||
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
|
||||
req.setRawHeader("X-App-Id", service_->app_id().toUtf8());
|
||||
req.setRawHeader("X-User-Auth-Token", service_->user_auth_token().toUtf8());
|
||||
QNetworkReply *reply = network_->get(req);
|
||||
QNetworkRequest network_request(url);
|
||||
network_request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
network_request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
|
||||
network_request.setRawHeader("X-App-Id", service_->app_id().toUtf8());
|
||||
network_request.setRawHeader("X-User-Auth-Token", service_->user_auth_token().toUtf8());
|
||||
QNetworkReply *reply = network_->get(network_request);
|
||||
replies_ << reply;
|
||||
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { HandleSearchReply(reply, id); });
|
||||
|
||||
@@ -114,46 +114,56 @@ bool QobuzCoverProvider::StartSearch(const QString &artist, const QString &album
|
||||
|
||||
void QobuzCoverProvider::CancelSearch(const int id) { Q_UNUSED(id); }
|
||||
|
||||
QByteArray QobuzCoverProvider::GetReplyData(QNetworkReply *reply) {
|
||||
JsonBaseRequest::JsonObjectResult QobuzCoverProvider::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.
|
||||
Error(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("code"_L1) && json_object.contains("status"_L1) && json_object.contains("message"_L1)) {
|
||||
const int code = json_object["code"_L1].toInt();
|
||||
const QString message = json_object["message"_L1].toString();
|
||||
result.error_code = ErrorCode::APIError;
|
||||
result.error_message = QStringLiteral("%1 (%2)").arg(message).arg(code);
|
||||
}
|
||||
else {
|
||||
result.json_object = json_document.object();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// See if there is Json data containing "status", "code" and "message" - then use that instead.
|
||||
data = reply->readAll();
|
||||
QString error;
|
||||
QJsonParseError parse_error;
|
||||
QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error);
|
||||
if (parse_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
|
||||
QJsonObject json_obj = json_doc.object();
|
||||
if (!json_obj.isEmpty() && json_obj.contains("status"_L1) && json_obj.contains("code"_L1) && json_obj.contains("message"_L1)) {
|
||||
int code = json_obj["code"_L1].toInt();
|
||||
QString message = json_obj["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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (reply->error() == QNetworkReply::AuthenticationRequiredError) {
|
||||
service_->ClearSession();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
@@ -165,49 +175,45 @@ void QobuzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) {
|
||||
reply->deleteLater();
|
||||
|
||||
CoverProviderSearchResults results;
|
||||
const QScopeGuard search_finished = 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_type;
|
||||
if (json_obj.contains("albums"_L1)) {
|
||||
value_type = json_obj["albums"_L1];
|
||||
if (json_object.contains("albums"_L1)) {
|
||||
value_type = json_object["albums"_L1];
|
||||
}
|
||||
else if (json_obj.contains("tracks"_L1)) {
|
||||
value_type = json_obj["tracks"_L1];
|
||||
else if (json_object.contains("tracks"_L1)) {
|
||||
value_type = json_object["tracks"_L1];
|
||||
}
|
||||
else {
|
||||
Error(u"Json reply is missing albums and tracks object."_s, json_obj);
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
Error(u"Json reply is missing albums and tracks object."_s, json_object);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value_type.isObject()) {
|
||||
Error(u"Json albums or tracks is not a object."_s, value_type);
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
return;
|
||||
}
|
||||
QJsonObject obj_type = value_type.toObject();
|
||||
const QJsonObject object_type = value_type.toObject();
|
||||
|
||||
if (!obj_type.contains("items"_L1)) {
|
||||
Error(u"Json albums or tracks object does not contain items."_s, obj_type);
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
if (!object_type.contains("items"_L1)) {
|
||||
Error(u"Json albums or tracks object does not contain items."_s, object_type);
|
||||
return;
|
||||
}
|
||||
QJsonValue value_items = obj_type["items"_L1];
|
||||
const QJsonValue value_items = object_type["items"_L1];
|
||||
|
||||
if (!value_items.isArray()) {
|
||||
Error(u"Json albums or track object items is not a array."_s, value_items);
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
return;
|
||||
}
|
||||
const QJsonArray array_items = value_items.toArray();
|
||||
@@ -218,52 +224,52 @@ void QobuzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) {
|
||||
Error(u"Invalid Json reply, value in items is not a object."_s);
|
||||
continue;
|
||||
}
|
||||
QJsonObject item_obj = value.toObject();
|
||||
const QJsonObject item_object = value.toObject();
|
||||
|
||||
QJsonObject obj_album;
|
||||
if (item_obj.contains("album"_L1)) {
|
||||
if (!item_obj["album"_L1].isObject()) {
|
||||
Error(u"Invalid Json reply, items album is not a object."_s, item_obj);
|
||||
QJsonObject object_album;
|
||||
if (item_object.contains("album"_L1)) {
|
||||
if (!item_object["album"_L1].isObject()) {
|
||||
Error(u"Invalid Json reply, items album is not a object."_s, item_object);
|
||||
continue;
|
||||
}
|
||||
obj_album = item_obj["album"_L1].toObject();
|
||||
object_album = item_object["album"_L1].toObject();
|
||||
}
|
||||
else {
|
||||
obj_album = item_obj;
|
||||
object_album = item_object;
|
||||
}
|
||||
|
||||
if (!obj_album.contains("artist"_L1) || !obj_album.contains("image"_L1) || !obj_album.contains("title"_L1)) {
|
||||
Error(u"Invalid Json reply, item is missing artist, title or image."_s, obj_album);
|
||||
if (!object_album.contains("artist"_L1) || !object_album.contains("image"_L1) || !object_album.contains("title"_L1)) {
|
||||
Error(u"Invalid Json reply, item is missing artist, title or image."_s, object_album);
|
||||
continue;
|
||||
}
|
||||
|
||||
QString album = obj_album["title"_L1].toString();
|
||||
const QString album = object_album["title"_L1].toString();
|
||||
|
||||
// Artist
|
||||
QJsonValue value_artist = obj_album["artist"_L1];
|
||||
const QJsonValue value_artist = object_album["artist"_L1];
|
||||
if (!value_artist.isObject()) {
|
||||
Error(u"Invalid Json reply, items (album) artist is not a object."_s, value_artist);
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_artist = value_artist.toObject();
|
||||
if (!obj_artist.contains("name"_L1)) {
|
||||
Error(u"Invalid Json reply, items (album) artist is missing name."_s, obj_artist);
|
||||
const QJsonObject object_artist = value_artist.toObject();
|
||||
if (!object_artist.contains("name"_L1)) {
|
||||
Error(u"Invalid Json reply, items (album) artist is missing name."_s, object_artist);
|
||||
continue;
|
||||
}
|
||||
QString artist = obj_artist["name"_L1].toString();
|
||||
const QString artist = object_artist["name"_L1].toString();
|
||||
|
||||
// Image
|
||||
QJsonValue value_image = obj_album["image"_L1];
|
||||
if (!value_image.isObject()) {
|
||||
Error(u"Invalid Json reply, items (album) image is not a object."_s, value_image);
|
||||
const QJsonValue _timer_ = object_album["image"_L1];
|
||||
if (!_timer_.isObject()) {
|
||||
Error(u"Invalid Json reply, items (album) image is not a object."_s, _timer_);
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_image = value_image.toObject();
|
||||
if (!obj_image.contains("large"_L1)) {
|
||||
Error(u"Invalid Json reply, items (album) image is missing large."_s, obj_image);
|
||||
const QJsonObject object_image = _timer_.toObject();
|
||||
if (!object_image.contains("large"_L1)) {
|
||||
Error(u"Invalid Json reply, items (album) image is missing large."_s, object_image);
|
||||
continue;
|
||||
}
|
||||
QUrl cover_url(obj_image["large"_L1].toString());
|
||||
const QUrl cover_url(object_image["large"_L1].toString());
|
||||
|
||||
CoverProviderSearchResult cover_result;
|
||||
cover_result.artist = artist;
|
||||
@@ -273,7 +279,6 @@ void QobuzCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) {
|
||||
results << cover_result;
|
||||
|
||||
}
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -22,44 +22,37 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
#include <QSslError>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "jsoncoverprovider.h"
|
||||
#include "qobuz/qobuzservice.h"
|
||||
|
||||
class QNetworkReply;
|
||||
class NetworkAccessManager;
|
||||
class QobuzService;
|
||||
|
||||
class QobuzCoverProvider : public JsonCoverProvider {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit QobuzCoverProvider(const QobuzServicePtr service, SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
|
||||
~QobuzCoverProvider() override;
|
||||
explicit QobuzCoverProvider(const SharedPtr<QobuzService> service, SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
|
||||
|
||||
virtual bool authenticated() const override;
|
||||
|
||||
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
|
||||
void CancelSearch(const int id) override;
|
||||
|
||||
bool IsAuthenticated() const override { return service_ && service_->authenticated(); }
|
||||
void Deauthenticate() override { if (service_) service_->Logout(); }
|
||||
void ClearSession() override;
|
||||
|
||||
private Q_SLOTS:
|
||||
void HandleSearchReply(QNetworkReply *reply, const int id);
|
||||
|
||||
private:
|
||||
QByteArray GetReplyData(QNetworkReply *reply);
|
||||
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
|
||||
void Error(const QString &error, const QVariant &debug = QVariant()) override;
|
||||
|
||||
private:
|
||||
QobuzServicePtr service_;
|
||||
QList<QNetworkReply*> replies_;
|
||||
SharedPtr<QobuzService> service_;
|
||||
};
|
||||
|
||||
#endif // QOBUZCOVERPROVIDER_H
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2020-2024, 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,24 +19,14 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonValue>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QDesktopServices>
|
||||
#include <QMessageBox>
|
||||
#include <QScopeGuard>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "core/networkaccessmanager.h"
|
||||
@@ -57,20 +47,33 @@ SpotifyCoverProvider::SpotifyCoverProvider(const SpotifyServicePtr service, Shar
|
||||
: JsonCoverProvider(u"Spotify"_s, true, true, 2.5, true, true, network, parent),
|
||||
service_(service) {}
|
||||
|
||||
SpotifyCoverProvider::~SpotifyCoverProvider() {
|
||||
bool SpotifyCoverProvider::authenticated() const {
|
||||
|
||||
while (!replies_.isEmpty()) {
|
||||
QNetworkReply *reply = replies_.takeFirst();
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
return service_->authenticated();
|
||||
|
||||
}
|
||||
|
||||
bool SpotifyCoverProvider::use_authorization_header() const {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
QByteArray SpotifyCoverProvider::authorization_header() const {
|
||||
|
||||
return service_->authorization_header();
|
||||
|
||||
}
|
||||
|
||||
void SpotifyCoverProvider::ClearSession() {
|
||||
|
||||
service_->ClearSession();
|
||||
|
||||
}
|
||||
|
||||
bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &album, const QString &title, const int id) {
|
||||
|
||||
if (!IsAuthenticated()) return false;
|
||||
if (!service_->authenticated()) return false;
|
||||
|
||||
if (artist.isEmpty() && album.isEmpty() && title.isEmpty()) return false;
|
||||
|
||||
@@ -96,20 +99,7 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb
|
||||
<< Param(u"type"_s, type)
|
||||
<< Param(u"limit"_s, QString::number(kLimit));
|
||||
|
||||
QUrlQuery url_query;
|
||||
for (const Param ¶m : params) {
|
||||
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
|
||||
}
|
||||
|
||||
QUrl url(QLatin1String(kApiUrl) + u"/search"_s);
|
||||
url.setQuery(url_query);
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
|
||||
req.setRawHeader("Authorization", "Bearer " + service_->access_token().toUtf8());
|
||||
|
||||
QNetworkReply *reply = network_->get(req);
|
||||
replies_ << reply;
|
||||
QNetworkReply *reply = CreateGetRequest(QUrl(QLatin1String(kApiUrl) + u"/search"_s), params);
|
||||
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, id, extract]() { HandleSearchReply(reply, id, extract); });
|
||||
|
||||
return true;
|
||||
@@ -118,50 +108,59 @@ bool SpotifyCoverProvider::StartSearch(const QString &artist, const QString &alb
|
||||
|
||||
void SpotifyCoverProvider::CancelSearch(const int id) { Q_UNUSED(id); }
|
||||
|
||||
QByteArray SpotifyCoverProvider::GetReplyData(QNetworkReply *reply) {
|
||||
JsonBaseRequest::JsonObjectResult SpotifyCoverProvider::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.
|
||||
Error(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["error"_L1].isObject()) {
|
||||
const QJsonObject object_error = json_object["error"_L1].toObject();
|
||||
if (object_error.contains("status"_L1) && object_error.contains("message"_L1)) {
|
||||
const int status = object_error["status"_L1].toInt();
|
||||
const QString message = object_error["message"_L1].toString();
|
||||
result.error_code = ErrorCode::APIError;
|
||||
result.error_message = QStringLiteral("%1 (%2)").arg(message).arg(status);
|
||||
}
|
||||
}
|
||||
else {
|
||||
result.json_object = json_document.object();
|
||||
}
|
||||
}
|
||||
else {
|
||||
data = reply->readAll();
|
||||
QJsonParseError parse_error;
|
||||
QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error);
|
||||
QString error;
|
||||
if (parse_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
|
||||
QJsonObject json_obj = json_doc.object();
|
||||
if (!json_obj.isEmpty() && json_obj.contains("error"_L1) && json_obj["error"_L1].isObject()) {
|
||||
QJsonObject obj_error = json_obj["error"_L1].toObject();
|
||||
if (obj_error.contains("status"_L1) && obj_error.contains("message"_L1)) {
|
||||
int status = obj_error["status"_L1].toInt();
|
||||
QString message = obj_error["message"_L1].toString();
|
||||
error = QStringLiteral("%1 (%2)").arg(message).arg(status);
|
||||
if (status == 401) Deauthenticate();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error.isEmpty()) {
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
if (reply->error() == 204) Deauthenticate();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (reply->error() == QNetworkReply::AuthenticationRequiredError) {
|
||||
service_->ClearSession();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
@@ -172,48 +171,46 @@ void SpotifyCoverProvider::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());
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonObject json_obj = ExtractJsonObj(data);
|
||||
if (json_obj.isEmpty()) {
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json_obj.contains(extract) || !json_obj[extract].isObject()) {
|
||||
Error(QStringLiteral("Json object is missing %1 object.").arg(extract), json_obj);
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
json_obj = json_obj[extract].toObject();
|
||||
|
||||
if (!json_obj.contains("items"_L1) || !json_obj["items"_L1].isArray()) {
|
||||
Error(QStringLiteral("%1 object is missing items array.").arg(extract), json_obj);
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray array_items = json_obj["items"_L1].toArray();
|
||||
if (array_items.isEmpty()) {
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
QJsonObject json_object = json_object_result.json_object;
|
||||
if (json_object.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json_object.contains(extract) || !json_object[extract].isObject()) {
|
||||
Error(QStringLiteral("Json object is missing %1 object.").arg(extract), json_object);
|
||||
return;
|
||||
}
|
||||
json_object = json_object[extract].toObject();
|
||||
|
||||
if (!json_object.contains("items"_L1) || !json_object["items"_L1].isArray()) {
|
||||
Error(QStringLiteral("%1 object is missing items array.").arg(extract), json_object);
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray array_items = json_object["items"_L1].toArray();
|
||||
if (array_items.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const QJsonValue &value_item : array_items) {
|
||||
|
||||
if (!value_item.isObject()) {
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_item = value_item.toObject();
|
||||
const QJsonObject object_item = value_item.toObject();
|
||||
|
||||
QJsonObject obj_album = obj_item;
|
||||
if (obj_item.contains("album"_L1) && obj_item["album"_L1].isObject()) {
|
||||
obj_album = obj_item["album"_L1].toObject();
|
||||
QJsonObject obj_album = object_item;
|
||||
if (object_item.contains("album"_L1) && object_item["album"_L1].isObject()) {
|
||||
obj_album = object_item["album"_L1].toObject();
|
||||
}
|
||||
|
||||
if (!obj_album.contains("artists"_L1) || !obj_album.contains("name"_L1) || !obj_album.contains("images"_L1) || !obj_album["artists"_L1].isArray() || !obj_album["images"_L1].isArray()) {
|
||||
@@ -221,7 +218,7 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id,
|
||||
}
|
||||
const QJsonArray array_artists = obj_album["artists"_L1].toArray();
|
||||
const QJsonArray array_images = obj_album["images"_L1].toArray();
|
||||
QString album = obj_album["name"_L1].toString();
|
||||
const QString album = obj_album["name"_L1].toString();
|
||||
|
||||
QStringList artists;
|
||||
for (const QJsonValue &value_artist : array_artists) {
|
||||
@@ -233,12 +230,12 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id,
|
||||
|
||||
for (const QJsonValue &value_image : array_images) {
|
||||
if (!value_image.isObject()) continue;
|
||||
QJsonObject obj_image = value_image.toObject();
|
||||
if (!obj_image.contains("url"_L1) || !obj_image.contains("width"_L1) || !obj_image.contains("height"_L1)) continue;
|
||||
int width = obj_image["width"_L1].toInt();
|
||||
int height = obj_image["height"_L1].toInt();
|
||||
const QJsonObject object_image = value_image.toObject();
|
||||
if (!object_image.contains("url"_L1) || !object_image.contains("width"_L1) || !object_image.contains("height"_L1)) continue;
|
||||
const int width = object_image["width"_L1].toInt();
|
||||
const int height = object_image["height"_L1].toInt();
|
||||
if (width < 300 || height < 300) continue;
|
||||
QUrl url(obj_image["url"_L1].toString());
|
||||
const QUrl url(object_image["url"_L1].toString());
|
||||
CoverProviderSearchResult result;
|
||||
result.album = album;
|
||||
result.image_url = url;
|
||||
@@ -248,7 +245,6 @@ void SpotifyCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id,
|
||||
}
|
||||
|
||||
}
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* 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
|
||||
@@ -22,50 +22,41 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QSet>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QSslError>
|
||||
#include <QJsonValue>
|
||||
#include <QJsonObject>
|
||||
#include <QTimer>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "jsoncoverprovider.h"
|
||||
#include "spotify/spotifyservice.h"
|
||||
|
||||
class QNetworkReply;
|
||||
class NetworkAccessManager;
|
||||
class SpotifyService;
|
||||
|
||||
using SpotifyServicePtr = SharedPtr<SpotifyService>;
|
||||
|
||||
class SpotifyCoverProvider : public JsonCoverProvider {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SpotifyCoverProvider(const SpotifyServicePtr service, const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
|
||||
~SpotifyCoverProvider() override;
|
||||
|
||||
virtual bool authenticated() const override;
|
||||
virtual bool use_authorization_header() const override;
|
||||
virtual QByteArray authorization_header() const override;
|
||||
|
||||
bool StartSearch(const QString &artist, const QString &album, const QString &title, const int id) override;
|
||||
void CancelSearch(const int id) override;
|
||||
|
||||
bool IsAuthenticated() const override { return service_ && service_->authenticated(); }
|
||||
void Deauthenticate() override {
|
||||
if (service_) service_->Deauthenticate();
|
||||
}
|
||||
void ClearSession() override;
|
||||
|
||||
private Q_SLOTS:
|
||||
void HandleSearchReply(QNetworkReply *reply, const int id, const QString &extract);
|
||||
|
||||
private:
|
||||
QByteArray GetReplyData(QNetworkReply *reply);
|
||||
JsonObjectResult ParseJsonObject(QNetworkReply *reply);
|
||||
void Error(const QString &error, const QVariant &debug = QVariant()) override;
|
||||
|
||||
private:
|
||||
const SharedPtr<SpotifyService> service_;
|
||||
QList<QNetworkReply*> replies_;
|
||||
const SpotifyServicePtr service_;
|
||||
};
|
||||
|
||||
#endif // SPOTIFYCOVERPROVIDER_H
|
||||
|
||||
@@ -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,20 +19,17 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QtGlobal>
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonValue>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QScopeGuard>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "core/networkaccessmanager.h"
|
||||
@@ -47,20 +44,33 @@ using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace {
|
||||
constexpr int kLimit = 10;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TidalCoverProvider::TidalCoverProvider(const TidalServicePtr service, const SharedPtr<NetworkAccessManager> network, QObject *parent)
|
||||
: JsonCoverProvider(u"Tidal"_s, true, true, 2.5, true, true, network, parent),
|
||||
service_(service) {}
|
||||
|
||||
TidalCoverProvider::~TidalCoverProvider() {
|
||||
bool TidalCoverProvider::authenticated() const {
|
||||
|
||||
while (!replies_.isEmpty()) {
|
||||
QNetworkReply *reply = replies_.takeFirst();
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
}
|
||||
return service_->authenticated();
|
||||
|
||||
}
|
||||
|
||||
bool TidalCoverProvider::use_authorization_header() const {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
QByteArray TidalCoverProvider::authorization_header() const {
|
||||
|
||||
return service_->authorization_header();
|
||||
|
||||
}
|
||||
|
||||
void TidalCoverProvider::ClearSession() {
|
||||
|
||||
service_->ClearSession();
|
||||
|
||||
}
|
||||
|
||||
@@ -89,21 +99,7 @@ bool TidalCoverProvider::StartSearch(const QString &artist, const QString &album
|
||||
<< Param(u"limit"_s, QString::number(kLimit))
|
||||
<< Param(u"countryCode"_s, service_->country_code());
|
||||
|
||||
QUrlQuery url_query;
|
||||
for (const Param ¶m : params) {
|
||||
url_query.addQueryItem(QString::fromLatin1(QUrl::toPercentEncoding(param.first)), QString::fromLatin1(QUrl::toPercentEncoding(param.second)));
|
||||
}
|
||||
|
||||
QUrl url(QLatin1String(TidalService::kApiUrl) + QLatin1Char('/') + resource);
|
||||
url.setQuery(url_query);
|
||||
QNetworkRequest req(url);
|
||||
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, u"application/x-www-form-urlencoded"_s);
|
||||
if (service_->oauth() && !service_->access_token().isEmpty()) req.setRawHeader("authorization", "Bearer " + service_->access_token().toUtf8());
|
||||
else if (!service_->session_id().isEmpty()) req.setRawHeader("X-Tidal-SessionId", service_->session_id().toUtf8());
|
||||
|
||||
QNetworkReply *reply = network_->get(req);
|
||||
replies_ << reply;
|
||||
QNetworkReply *reply = CreateGetRequest(QUrl(QLatin1String(TidalService::kApiUrl) + QLatin1Char('/') + resource), params);
|
||||
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { HandleSearchReply(reply, id); });
|
||||
|
||||
return true;
|
||||
@@ -112,52 +108,62 @@ bool TidalCoverProvider::StartSearch(const QString &artist, const QString &album
|
||||
|
||||
void TidalCoverProvider::CancelSearch(const int id) { Q_UNUSED(id); }
|
||||
|
||||
QByteArray TidalCoverProvider::GetReplyData(QNetworkReply *reply) {
|
||||
JsonBaseRequest::JsonObjectResult TidalCoverProvider::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.
|
||||
Error(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();
|
||||
bool clear_session = false;
|
||||
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("status"_L1) && json_object.contains("subStatus"_L1) && json_object.contains("userMessage"_L1)) {
|
||||
const int status = json_object["status"_L1].toInt();
|
||||
const int sub_status = json_object["subStatus"_L1].toInt();
|
||||
const QString user_message = json_object["userMessage"_L1].toString();
|
||||
result.error_code = ErrorCode::APIError;
|
||||
result.api_error = status;
|
||||
result.error_message = QStringLiteral("%1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status);
|
||||
if (status == 401 && sub_status == 6001) {
|
||||
clear_session = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
result.json_object = json_document.object();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// See if there is Json data containing "status" and "userMessage" - then use that instead.
|
||||
data = reply->readAll();
|
||||
QJsonParseError parse_error;
|
||||
QJsonDocument json_doc = QJsonDocument::fromJson(data, &parse_error);
|
||||
int status = 0;
|
||||
int sub_status = 0;
|
||||
QString error;
|
||||
if (parse_error.error == QJsonParseError::NoError && !json_doc.isEmpty() && json_doc.isObject()) {
|
||||
QJsonObject json_obj = json_doc.object();
|
||||
if (!json_obj.isEmpty() && json_obj.contains("status"_L1) && json_obj.contains("userMessage"_L1)) {
|
||||
status = json_obj["status"_L1].toInt();
|
||||
sub_status = json_obj["subStatus"_L1].toInt();
|
||||
QString user_message = json_obj["userMessage"_L1].toString();
|
||||
error = QStringLiteral("%1 (%2) (%3)").arg(user_message).arg(status).arg(sub_status);
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
if (status == 401 && sub_status == 6001) { // User does not have a valid session
|
||||
service_->Logout();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (reply->error() == QNetworkReply::AuthenticationRequiredError || clear_session) {
|
||||
service_->ClearSession();
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
@@ -168,36 +174,34 @@ void TidalCoverProvider::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;
|
||||
}
|
||||
|
||||
QJsonObject json_obj = ExtractJsonObj(data);
|
||||
if (json_obj.isEmpty()) {
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
const QJsonObject json_object = json_object_result.json_object;
|
||||
if (json_object.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json_obj.contains("items"_L1)) {
|
||||
Error(u"Json object is missing items."_s, json_obj);
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
if (!json_object.contains("items"_L1)) {
|
||||
Error(u"Json object is missing items."_s, json_object);
|
||||
return;
|
||||
}
|
||||
QJsonValue value_items = json_obj["items"_L1];
|
||||
const QJsonValue value_items = json_object["items"_L1];
|
||||
|
||||
if (!value_items.isArray()) {
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
const QJsonArray array_items = value_items.toArray();
|
||||
if (array_items.isEmpty()) {
|
||||
Q_EMIT SearchFinished(id, CoverProviderSearchResults());
|
||||
return;
|
||||
}
|
||||
|
||||
CoverProviderSearchResults results;
|
||||
int i = 0;
|
||||
for (const QJsonValue &value_item : array_items) {
|
||||
|
||||
@@ -205,29 +209,29 @@ void TidalCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) {
|
||||
Error(u"Invalid Json reply, items array item is not a object."_s);
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_item = value_item.toObject();
|
||||
const QJsonObject object_item = value_item.toObject();
|
||||
|
||||
if (!obj_item.contains("artist"_L1)) {
|
||||
Error(u"Invalid Json reply, items array item is missing artist."_s, obj_item);
|
||||
if (!object_item.contains("artist"_L1)) {
|
||||
Error(u"Invalid Json reply, items array item is missing artist."_s, object_item);
|
||||
continue;
|
||||
}
|
||||
QJsonValue value_artist = obj_item["artist"_L1];
|
||||
const QJsonValue value_artist = object_item["artist"_L1];
|
||||
if (!value_artist.isObject()) {
|
||||
Error(u"Invalid Json reply, items array item artist is not a object."_s, value_artist);
|
||||
continue;
|
||||
}
|
||||
QJsonObject obj_artist = value_artist.toObject();
|
||||
if (!obj_artist.contains("name"_L1)) {
|
||||
Error(u"Invalid Json reply, items array item artist is missing name."_s, obj_artist);
|
||||
const QJsonObject object_artist = value_artist.toObject();
|
||||
if (!object_artist.contains("name"_L1)) {
|
||||
Error(u"Invalid Json reply, items array item artist is missing name."_s, object_artist);
|
||||
continue;
|
||||
}
|
||||
QString artist = obj_artist["name"_L1].toString();
|
||||
const QString artist = object_artist["name"_L1].toString();
|
||||
|
||||
QJsonObject obj_album;
|
||||
if (obj_item.contains("album"_L1)) {
|
||||
QJsonValue value_album = obj_item["album"_L1];
|
||||
QJsonObject object_album;
|
||||
if (object_item.contains("album"_L1)) {
|
||||
QJsonValue value_album = object_item["album"_L1];
|
||||
if (value_album.isObject()) {
|
||||
obj_album = value_album.toObject();
|
||||
object_album = value_album.toObject();
|
||||
}
|
||||
else {
|
||||
Error(u"Invalid Json reply, items array item album is not a object."_s, value_album);
|
||||
@@ -235,15 +239,15 @@ void TidalCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
obj_album = obj_item;
|
||||
object_album = object_item;
|
||||
}
|
||||
|
||||
if (!obj_album.contains("title"_L1) || !obj_album.contains("cover"_L1)) {
|
||||
Error(u"Invalid Json reply, items array item album is missing title or cover."_s, obj_album);
|
||||
if (!object_album.contains("title"_L1) || !object_album.contains("cover"_L1)) {
|
||||
Error(u"Invalid Json reply, items array item album is missing title or cover."_s, object_album);
|
||||
continue;
|
||||
}
|
||||
QString album = obj_album["title"_L1].toString();
|
||||
QString cover = obj_album["cover"_L1].toString().replace("-"_L1, "/"_L1);
|
||||
const QString album = object_album["title"_L1].toString();
|
||||
const QString cover = object_album["cover"_L1].toString().replace("-"_L1, "/"_L1);
|
||||
|
||||
CoverProviderSearchResult cover_result;
|
||||
cover_result.artist = artist;
|
||||
@@ -261,7 +265,6 @@ void TidalCoverProvider::HandleSearchReply(QNetworkReply *reply, const int id) {
|
||||
}
|
||||
|
||||
}
|
||||
Q_EMIT SearchFinished(id, results);
|
||||
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user