Compare commits

..

1 Commits

Author SHA1 Message Date
Jonas Kvinge
46f368a68e Add podcasts support WIP 2025-12-29 00:40:01 +01:00
83 changed files with 10968 additions and 28 deletions

View File

@@ -81,7 +81,7 @@ jobs:
gtest
gmock
sparsehash-devel
discord-rpc-devel
rapidjson-devel
- name: Install kdsingleapplication-qt6-devel
if: matrix.opensuse_version != 'leap:15.6'
run: zypper -n --gpg-auto-import-keys in kdsingleapplication-qt6-devel
@@ -207,7 +207,7 @@ jobs:
gtest-devel
gmock-devel
sparsehash-devel
discord-rpc-devel
rapidjson-devel
- name: Checkout
uses: actions/checkout@v6
with:
@@ -303,7 +303,7 @@ jobs:
appstream
appstream-util
hicolor-icon-theme
discord-rpc-devel
rapidjson
- name: Remove files
run: rm -rf /usr/lib64/qt6/lib/cmake/Qt6Sql/{Qt6QMYSQL*,Qt6QODBCD*,Qt6QPSQL*,Qt6QIBase*}
- name: Checkout
@@ -399,7 +399,7 @@ jobs:
appstream-util
hicolor-icon-theme
gtest
discord-rpc-devel
rapidjson
- name: Build and install KDSingleApplication
if: matrix.mageia_version == '9'
run: |
@@ -520,7 +520,7 @@ jobs:
- name: Create Build Environment
run: cmake -E make_directory build
- name: Configure CMake
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_WERROR=ON -DENABLE_DISCORD_RPC=OFF
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_WERROR=ON
- name: Delete build directory
run: rm -rf build
- name: make deb
@@ -616,7 +616,7 @@ jobs:
- name: Create Build Environment
run: cmake -E make_directory build
- name: Configure CMake
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_WERROR=ON -DENABLE_DISCORD_RPC=OFF
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_WERROR=ON
- name: Delete build directory
run: rm -rf build
- name: make deb
@@ -692,6 +692,7 @@ jobs:
gstreamer1.0-alsa
gstreamer1.0-pulseaudio
libkdsingleapplication-qt6-dev
rapidjson-dev
- name: Install keyboxd
if: matrix.ubuntu_version == 'noble'
env:
@@ -711,7 +712,7 @@ jobs:
- name: Create Build Environment
run: cmake -E make_directory build
- name: Configure CMake
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_WERROR=ON -DENABLE_DISCORD_RPC=OFF
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/usr -DBUILD_WERROR=ON
- name: Delete build directory
run: rm -rf build
- name: Import Ubuntu PPA GPG private key
@@ -755,7 +756,7 @@ jobs:
set -e
git config --global --add safe.directory ${GITHUB_WORKSPACE}
cmake -E make_directory build
cmake -S . -B build -DCMAKE_BUILD_TYPE="Debug" -DENABLE_DISCORD_RPC=OFF
cmake -S . -B build -DCMAKE_BUILD_TYPE="Debug"
cmake --build build --config Debug --parallel 4
@@ -775,7 +776,7 @@ jobs:
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 sparsehash
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"

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -208,22 +208,13 @@ else()
pkg_check_modules(TAGLIB REQUIRED IMPORTED_TARGET taglib>=1.12)
endif()
pkg_check_modules(LIBMYGPO libmygpo-qt6)
find_package(GTest)
pkg_check_modules(LIBSPARSEHASH IMPORTED_TARGET libsparsehash)
find_package(discord-rpc)
if(TARGET discord-rpc::discord-rpc)
set(DISCORD_RPC_FOUND ON)
set(DISCORD_RPC_LIBRARIES "discord-rpc::discord-rpc")
else()
find_library(DISCORD_RPC_LIBRARY discord-rpc)
find_path(DISCORD_RPC_INCLUDE_DIRS NAMES discord-rpc.h)
if(DISCORD_RPC_LIBRARY)
set(DISCORD_RPC_FOUND ON)
set(DISCORD_RPC_LIBRARIES ${DISCORD_RPC_LIBRARY})
endif()
endif()
find_package(RapidJSON)
set(QT_VERSION_MAJOR 6)
set(QT_MIN_VERSION 6.4.0)
@@ -389,9 +380,11 @@ optional_component(STREAMTAGREADER ON "Stream tagreader"
)
optional_component(DISCORD_RPC ON "Discord Rich Presence"
DEPENDS "discord-rpc" DISCORD_RPC_FOUND
DEPENDS "RapidJSON" RapidJSON_FOUND
)
optional_component(PODCASTS ON "Podcasts support" DEPENDS "libmygpo" LIBMYGPO_FOUND)
if(HAVE_SONGFINGERPRINTING OR HAVE_MUSICBRAINZ)
set(HAVE_CHROMAPRINT ON)
endif()
@@ -1491,6 +1484,69 @@ optional_source(HAVE_QOBUZ
src/settings/qobuzsettingspage.ui
)
optional_source(HAVE_PODCASTS
SOURCES
podcasts/gpoddersync.cpp
podcasts/gpoddertoptagsmodel.cpp
podcasts/gpoddertoptagspage.cpp
podcasts/itunessearchpage.cpp
podcasts/podcastbackend.cpp
podcasts/podcastservice.cpp
podcasts/podcast.cpp
podcasts/podcastdownloader.cpp
podcasts/podcastupdater.cpp
podcasts/podcastdeleter.cpp
podcasts/podcastdiscoverymodel.cpp
podcasts/podcastepisode.cpp
podcasts/podcastinfodialog.cpp
podcasts/podcastinfowidget.cpp
podcasts/podcastparser.cpp
podcasts/podcastservicemodel.cpp
podcasts/podcasturlloader.cpp
podcasts/gpoddersearchpage.cpp
podcasts/addpodcastbyurl.cpp
podcasts/addpodcastdialog.cpp
podcasts/addpodcastpage.cpp
podcasts/episodeinfowidget.cpp
podcasts/fixedopmlpage.cpp
settings/podcastsettingspage.cpp
HEADERS
podcasts/addpodcastbyurl.h
podcasts/addpodcastdialog.h
podcasts/addpodcastpage.h
podcasts/episodeinfowidget.h
podcasts/fixedopmlpage.h
podcasts/gpoddersync.h
podcasts/gpoddertoptagsmodel.h
podcasts/gpoddertoptagspage.h
podcasts/itunessearchpage.h
podcasts/opmlcontainer.h
podcasts/podcastbackend.h
podcasts/podcastdeleter.h
podcasts/podcastdiscoverymodel.h
podcasts/podcastdownloader.h
podcasts/podcastepisode.h
podcasts/podcast.h
podcasts/podcastinfodialog.h
podcasts/podcastinfowidget.h
podcasts/podcastparser.h
podcasts/podcastservice.h
podcasts/podcastservicemodel.h
podcasts/podcastupdater.h
podcasts/podcasturlloader.h
podcasts/gpoddersearchpage.h
settings/podcastsettingspage.h
UI
podcasts/addpodcastbyurl.ui
podcasts/addpodcastdialog.ui
podcasts/episodeinfowidget.ui
podcasts/itunessearchpage.ui
podcasts/podcastinfodialog.ui
podcasts/podcastinfowidget.ui
podcasts/gpoddersearchpage.ui
settings/podcastsettingspage.ui
)
qt_wrap_cpp(SOURCES ${HEADERS})
qt_wrap_ui(SOURCES ${UI})
qt_add_resources(SOURCES data/data.qrc data/icons.qrc)
@@ -1514,6 +1570,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)
@@ -1535,10 +1596,6 @@ if(SINGLEAPPLICATION_INCLUDE_DIRS)
target_include_directories(strawberry_lib SYSTEM PUBLIC ${SINGLEAPPLICATION_INCLUDE_DIRS})
endif()
if(DISCORD_RPC_INCLUDE_DIRS)
target_include_directories(strawberry_lib SYSTEM PUBLIC ${DISCORD_RPC_INCLUDE_DIRS})
endif()
target_link_libraries(strawberry_lib PUBLIC
${CMAKE_THREAD_LIBS_INIT}
$<$<BOOL:${HAVE_BACKTRACE}>:${Backtrace_LIBRARIES}>
@@ -1578,7 +1635,7 @@ target_link_libraries(strawberry_lib PUBLIC
$<$<BOOL:${WIN32}>:dsound dwmapi ${GETOPT_LIBRARIES}>
$<$<BOOL:${MSVC}>:WindowsApp>
KDAB::kdsingleapplication
$<$<BOOL:${HAVE_DISCORD_RPC}>:${DISCORD_RPC_LIBRARIES}>
$<$<BOOL:${HAVE_DISCORD_RPC}>:discord-rpc>
)
if(APPLE)

View File

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

View File

@@ -113,7 +113,16 @@
#include "radios/radioservices.h"
#include "radios/radiobackend.h"
#ifdef HAVE_PODCASTS
# include "podcasts/podcastbackend.h"
# include "podcasts/gpoddersync.h"
# include "podcasts/podcastdownloader.h"
# include "podcasts/podcastupdater.h"
# include "podcasts/podcastdeleter.h"
#endif
using std::make_shared;
using namespace std::chrono_literals;
class ApplicationImpl {
@@ -216,6 +225,21 @@ class ApplicationImpl {
#ifdef HAVE_MOODBAR
moodbar_loader_([app]() { return new MoodbarLoader(app); }),
moodbar_controller_([app]() { return new MoodbarController(app->player(), app->moodbar_loader()); }),
#endif
#ifdef HAVE_PODCASTS
podcast_backend_([app]() {
PodcastBackend* backend = new PodcastBackend(app, app);
app->MoveToThread(backend, database_->thread());
return backend;
}),
gpodder_sync_([app]() { return new GPodderSync(app, app); }),
podcast_downloader_([app]() { return new PodcastDownloader(app, app); }),
podcast_updater_([app]() { return new PodcastUpdater(app, app); }),
podcast_deleter_([app]() {
PodcastDeleter* deleter = new PodcastDeleter(app, app);
app->MoveToNewThread(deleter);
return deleter;
}),
#endif
lastfm_import_([app]() { return new LastFMImport(app->network()); })
{}
@@ -241,6 +265,13 @@ class ApplicationImpl {
#ifdef HAVE_MOODBAR
Lazy<MoodbarLoader> moodbar_loader_;
Lazy<MoodbarController> moodbar_controller_;
#endif
#ifdef HAVE_PODCASTS
Lazy<PodcastBackend> podcast_backend_;
Lazy<GPodderSync> gpodder_sync_;
Lazy<PodcastDownloader> podcast_downloader_;
Lazy<PodcastUpdater> podcast_updater_;
Lazy<PodcastDeleter> podcast_deleter_;
#endif
Lazy<LastFMImport> lastfm_import_;
@@ -390,3 +421,10 @@ SharedPtr<LastFMImport> Application::lastfm_import() const { return p_->lastfm_i
SharedPtr<MoodbarController> Application::moodbar_controller() const { return p_->moodbar_controller_.ptr(); }
SharedPtr<MoodbarLoader> Application::moodbar_loader() const { return p_->moodbar_loader_.ptr(); }
#endif
#ifdef HAVE_PODCASTS
PodcastBackend *Application::podcast_backend() const { return p_->podcast_backend_.get(); }
GPodderSync *Application::gpodder_sync() const { return p_->gpodder_sync_.get(); }
PodcastDownloader *Application::podcast_downloader() const { return p_->podcast_downloader_.get(); }
PodcastUpdater *Application::podcast_updater() const { return p_->podcast_updater_.get(); }
PodcastDeleter *Application::podcast_deleter() const { return p_->podcast_deleter_.get(); }
#endif

View File

@@ -63,6 +63,14 @@ class RadioServices;
class MoodbarController;
class MoodbarLoader;
#endif
#ifdef HAVE_PODCASTS
class PodcastBackend;
class GPodderSync;
class PodcastDownloader;
class PodcastUpdater;
class PodcastDeleter;
#endif
class Application : public QObject {
Q_OBJECT
@@ -102,6 +110,13 @@ class Application : public QObject {
SharedPtr<MoodbarController> moodbar_controller() const;
SharedPtr<MoodbarLoader> moodbar_loader() const;
#endif
#ifdef HAVE_PODCASTS
PodcastBackend *podcast_backend() const;
GPodderSync *gpodder_sync() const;
PodcastDownloader *podcast_downloader() const;
PodcastUpdater *podcast_updater() const;
PodcastDeleter *podcast_deleter() const;
#endif
SharedPtr<LastFMImport> lastfm_import() const;

View File

@@ -75,7 +75,7 @@ void RichPresence::ReloadSettings() {
s.endGroup();
if (enabled && !initialized_) {
Discord_Initialize(kDiscordApplicationId, nullptr, 0, nullptr);
Discord_Initialize(kDiscordApplicationId, nullptr, 0);
initialized_ = true;
}
else if (!enabled && initialized_) {
@@ -124,6 +124,7 @@ void RichPresence::SendPresenceUpdate() {
// Listening to
presence_data.type = 2;
presence_data.status_display_type = status_display_type_;
presence_data.largeImageKey = kStrawberryIconResourceName;
presence_data.smallImageKey = kStrawberryIconResourceName;
presence_data.smallImageText = kStrawberryIconDescription;

View File

@@ -0,0 +1,116 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QString>
#include <QUrl>
#include <QClipboard>
#include <QMessageBox>
#include "core/iconloader.h"
#include "podcastdiscoverymodel.h"
#include "podcasturlloader.h"
#include "addpodcastbyurl.h"
#include "ui_addpodcastbyurl.h"
AddPodcastByUrl::AddPodcastByUrl(Application *app, QWidget *parent)
: AddPodcastPage(app, parent),
ui_(new Ui_AddPodcastByUrl),
loader_(new PodcastUrlLoader(this)) {
ui_->setupUi(this);
QObject::connect(ui_->go, &QPushButton::clicked, this, &AddPodcastByUrl::GoClicked);
setWindowIcon(IconLoader::Load("podcast"));
}
AddPodcastByUrl::~AddPodcastByUrl() { delete ui_; }
void AddPodcastByUrl::SetUrlAndGo(const QUrl &url) {
ui_->url->setText(url.toString());
GoClicked();
}
void AddPodcastByUrl::SetOpml(const OpmlContainer &opml) {
ui_->url->setText(opml.url.toString());
model()->clear();
model()->CreateOpmlContainerItems(opml, model()->invisibleRootItem());
}
void AddPodcastByUrl::GoClicked() {
emit Busy(true);
model()->clear();
PodcastUrlLoaderReply* reply = loader_->Load(ui_->url->text());
ui_->url->setText(reply->url().toString());
QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply]() { RequestFinished(reply); });
}
void AddPodcastByUrl::RequestFinished(PodcastUrlLoaderReply *reply) {
reply->deleteLater();
emit Busy(false);
if (!reply->is_success()) {
QMessageBox::warning(this, tr("Failed to load podcast"), reply->error_text(), QMessageBox::Close);
return;
}
switch (reply->result_type()) {
case PodcastUrlLoaderReply::Type_Podcast:
for (const Podcast& podcast : reply->podcast_results()) {
model()->appendRow(model()->CreatePodcastItem(podcast));
}
break;
case PodcastUrlLoaderReply::Type_Opml:
model()->CreateOpmlContainerItems(reply->opml_results(), model()->invisibleRootItem());
break;
}
}
void AddPodcastByUrl::Show() {
ui_->url->setFocus();
const QClipboard *clipboard = QApplication::clipboard();
QStringList contents;
contents << clipboard->text(QClipboard::Selection) << clipboard->text(QClipboard::Clipboard);
for (const QString &content : contents) {
if (content.contains("://")) {
ui_->url->setText(content);
return;
}
}
}

View File

@@ -0,0 +1,60 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ADDPODCASTBYURL_H
#define ADDPODCASTBYURL_H
#include <QObject>
#include <QUrl>
#include "addpodcastpage.h"
#include "opmlcontainer.h"
class Application;
class AddPodcastPage;
class PodcastUrlLoader;
class PodcastUrlLoaderReply;
class Ui_AddPodcastByUrl;
class AddPodcastByUrl : public AddPodcastPage {
Q_OBJECT
public:
explicit AddPodcastByUrl(Application *app, QWidget *parent = nullptr);
~AddPodcastByUrl();
void Show();
void SetOpml(const OpmlContainer &opml);
void SetUrlAndGo(const QUrl &url);
private slots:
void GoClicked();
void RequestFinished(PodcastUrlLoaderReply *reply);
private:
Ui_AddPodcastByUrl *ui_;
PodcastUrlLoader *loader_;
};
#endif // ADDPODCASTBYURL_H

View File

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

View File

@@ -0,0 +1,270 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QDialog>
#include <QVariant>
#include <QString>
#include <QUrl>
#include <QDir>
#include <QFileDialog>
#include <QTimer>
#include <QPushButton>
#include <QListWidget>
#include <QItemSelectionModel>
#include "core/application.h"
#include "core/iconloader.h"
#include "widgets/widgetfadehelper.h"
#include "fixedopmlpage.h"
#include "gpoddersearchpage.h"
#include "gpoddertoptagspage.h"
#include "itunessearchpage.h"
#include "podcastbackend.h"
#include "podcastdiscoverymodel.h"
#include "addpodcastbyurl.h"
#include "podcastinfowidget.h"
#include "addpodcastdialog.h"
#include "ui_addpodcastdialog.h"
const char *AddPodcastDialog::kBbcOpmlUrl = "http://www.bbc.co.uk/podcasts.opml";
const char *AddPodcastDialog::kCbcOpmlUrl = "http://cbc.ca/podcasts.opml";
AddPodcastDialog::AddPodcastDialog(Application *app, QWidget *parent)
: QDialog(parent),
app_(app),
ui_(new Ui_AddPodcastDialog),
last_opml_path_(QDir::homePath()) {
ui_->setupUi(this);
ui_->details->SetApplication(app);
ui_->results->SetExpandOnReset(false);
ui_->results->SetAddOnDoubleClick(false);
ui_->results_stack->setCurrentWidget(ui_->results_page);
fader_ = new WidgetFadeHelper(ui_->details_scroll_area);
QObject::connect(ui_->provider_list, &QListWidget::currentRowChanged, this, &AddPodcastDialog::ChangePage);
QObject::connect(ui_->details, &PodcastInfoWidget::LoadingFinished, fader_, &WidgetFadeHelper::StartFade);
QObject::connect(ui_->results, &AutoExpandingTreeView::doubleClicked, this, &AddPodcastDialog::PodcastDoubleClicked);
// Create Add and Remove Podcast buttons
add_button_ = new QPushButton(IconLoader::Load("list-add"), tr("Add Podcast"), this);
add_button_->setEnabled(false);
connect(add_button_, &QPushButton::clicked, this, &AddPodcastDialog::AddPodcast);
ui_->button_box->addButton(add_button_, QDialogButtonBox::ActionRole);
remove_button_ = new QPushButton(IconLoader::Load("list-remove"), tr("Unsubscribe"), this);
remove_button_->setEnabled(false);
connect(remove_button_, &QPushButton::clicked, this, &AddPodcastDialog::RemovePodcast);
ui_->button_box->addButton(remove_button_, QDialogButtonBox::ActionRole);
QPushButton *settings_button = new QPushButton(IconLoader::Load("configure"), tr("Configure podcasts..."), this);
connect(settings_button, &QPushButton::clicked, this, &AddPodcastDialog::OpenSettingsPage);
ui_->button_box->addButton(settings_button, QDialogButtonBox::ResetRole);
// Create an Open OPML file button
QPushButton *open_opml_button = new QPushButton(IconLoader::Load("document-open"), tr("Open OPML file..."), this);
QObject::connect(open_opml_button, &QPushButton::clicked, this, &AddPodcastDialog::OpenOPMLFile);
ui_->button_box->addButton(open_opml_button, QDialogButtonBox::ResetRole);
// Add providers
by_url_page_ = new AddPodcastByUrl(app, this);
AddPage(by_url_page_);
AddPage(new FixedOpmlPage(QUrl(kBbcOpmlUrl), tr("BBC Podcasts"), IconLoader::Load("bbc"), app, this));
AddPage(new FixedOpmlPage(QUrl(kCbcOpmlUrl), tr("CBC Podcasts"), IconLoader::Load("cbc"), app, this));
AddPage(new GPodderTopTagsPage(app, this));
AddPage(new GPodderSearchPage(app, this));
AddPage(new ITunesSearchPage(app, this));
ui_->provider_list->setCurrentRow(0);
}
AddPodcastDialog::~AddPodcastDialog() { delete ui_; }
void AddPodcastDialog::ShowWithUrl(const QUrl& url) {
by_url_page_->SetUrlAndGo(url);
ui_->provider_list->setCurrentRow(0);
show();
}
void AddPodcastDialog::ShowWithOpml(const OpmlContainer& opml) {
by_url_page_->SetOpml(opml);
ui_->provider_list->setCurrentRow(0);
show();
}
void AddPodcastDialog::AddPage(AddPodcastPage *page) {
pages_.append(page);
page_is_busy_.append(false);
ui_->stack->addWidget(page);
new QListWidgetItem(page->windowIcon(), page->windowTitle(), ui_->provider_list);
QObject::connect(page, &AddPodcastPage::Busy, this, &AddPodcastDialog::PageBusyChanged);
}
void AddPodcastDialog::ChangePage(const int index) {
AddPodcastPage *page = pages_[index];
ui_->stack->setCurrentIndex(index);
ui_->stack->setVisible(page->has_visible_widget());
ui_->results->setModel(page->model());
ui_->results_stack->setCurrentWidget(page_is_busy_[index] ? ui_->busy_page : ui_->results_page);
QObject::connect(ui_->results->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &AddPodcastDialog::ChangePodcast);
ChangePodcast(QModelIndex());
CurrentPageBusyChanged(page_is_busy_[index]);
page->Show();
}
void AddPodcastDialog::ChangePodcast(const QModelIndex &current) {
QVariant podcast_variant = current.data(PodcastDiscoveryModel::Role_Podcast);
// If the selected item is invalid or not a podcast, hide the details pane.
if (podcast_variant.isNull()) {
ui_->details_scroll_area->hide();
add_button_->setEnabled(false);
remove_button_->setEnabled(false);
return;
}
current_podcast_ = podcast_variant.value<Podcast>();
// Start the blur+fade if there's already a podcast in the details pane.
if (ui_->details_scroll_area->isVisible()) {
fader_->StartBlur();
}
else {
ui_->details_scroll_area->show();
}
// Update the details pane
ui_->details->SetPodcast(current_podcast_);
// Is the user already subscribed to this podcast?
Podcast subscribed_podcast = app_->podcast_backend()->GetSubscriptionByUrl(current_podcast_.url());
const bool is_subscribed = subscribed_podcast.url().isValid();
if (is_subscribed) {
// Use the one from the database which will contain the ID.
current_podcast_ = subscribed_podcast;
}
add_button_->setEnabled(!is_subscribed);
remove_button_->setEnabled(is_subscribed);
}
void AddPodcastDialog::PageBusyChanged(const bool busy) {
const int index = pages_.indexOf(qobject_cast<AddPodcastPage*>(sender()));
if (index == -1) return;
page_is_busy_[index] = busy;
if (index == ui_->provider_list->currentRow()) {
CurrentPageBusyChanged(busy);
}
}
void AddPodcastDialog::CurrentPageBusyChanged(const bool busy) {
ui_->results_stack->setCurrentWidget(busy ? ui_->busy_page : ui_->results_page);
ui_->stack->setDisabled(busy);
QTimer::singleShot(0, this, &AddPodcastDialog::SelectFirstPodcast);
}
void AddPodcastDialog::SelectFirstPodcast() {
// Select the first item if there was one.
const PodcastDiscoveryModel *model = pages_[ui_->provider_list->currentRow()]->model();
if (model->rowCount() > 0) {
ui_->results->selectionModel()->setCurrentIndex(model->index(0, 0), QItemSelectionModel::ClearAndSelect);
}
}
void AddPodcastDialog::AddPodcast() {
app_->podcast_backend()->Subscribe(&current_podcast_);
add_button_->setEnabled(false);
remove_button_->setEnabled(true);
}
void AddPodcastDialog::PodcastDoubleClicked(const QModelIndex &idx) {
QVariant podcast_variant = idx.data(PodcastDiscoveryModel::Role_Podcast);
if (podcast_variant.isNull()) {
return;
}
current_podcast_ = podcast_variant.value<Podcast>();
app_->podcast_backend()->Subscribe(&current_podcast_);
add_button_->setEnabled(false);
remove_button_->setEnabled(true);
}
void AddPodcastDialog::RemovePodcast() {
app_->podcast_backend()->Unsubscribe(current_podcast_);
current_podcast_.set_database_id(-1);
add_button_->setEnabled(true);
remove_button_->setEnabled(false);
}
void AddPodcastDialog::OpenSettingsPage() {
//app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Podcasts);
}
void AddPodcastDialog::OpenOPMLFile() {
const QString filename = QFileDialog::getOpenFileName(this, tr("Open OPML file"), last_opml_path_, "OPML files (*.opml)");
if (filename.isEmpty()) {
return;
}
last_opml_path_ = filename;
by_url_page_->SetUrlAndGo(QUrl::fromLocalFile(last_opml_path_));
ChangePage(ui_->stack->indexOf(by_url_page_));
}

View File

@@ -0,0 +1,91 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ADDPODCASTDIALOG_H
#define ADDPODCASTDIALOG_H
#include <QDialog>
#include <QList>
#include <QString>
#include <QUrl>
#include "podcast.h"
class Application;
class AddPodcastByUrl;
class AddPodcastPage;
class OpmlContainer;
class WidgetFadeHelper;
class Ui_AddPodcastDialog;
class AddPodcastDialog : public QDialog {
Q_OBJECT
public:
explicit AddPodcastDialog(Application *app, QWidget *parent = nullptr);
~AddPodcastDialog();
// Convenience methods that open the dialog at the Add By Url page and fill it with either a URL (which is then fetched), or a pre-fetched OPML container.
void ShowWithUrl(const QUrl &url);
void ShowWithOpml(const OpmlContainer &opml);
private slots:
void OpenSettingsPage();
void AddPodcast();
void PodcastDoubleClicked(const QModelIndex &idx);
void RemovePodcast();
void ChangePage(const int index);
void ChangePodcast(const QModelIndex &current);
void PageBusyChanged(const bool busy);
void CurrentPageBusyChanged(const bool busy);
void SelectFirstPodcast();
void OpenOPMLFile();
private:
void AddPage(AddPodcastPage *page);
private:
static const char *kBbcOpmlUrl;
static const char *kCbcOpmlUrl;
Application *app_;
Ui_AddPodcastDialog *ui_;
QPushButton *add_button_;
QPushButton *remove_button_;
QList<AddPodcastPage*> pages_;
QList<bool> page_is_busy_;
AddPodcastByUrl *by_url_page_;
WidgetFadeHelper *fader_;
Podcast current_podcast_;
QString last_opml_path_;
};
#endif // ADDPODCASTDIALOG_H

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ADDPODCASTPAGE_H
#define ADDPODCASTPAGE_H
#include <QWidget>
class Application;
class PodcastDiscoveryModel;
class AddPodcastPage : public QWidget {
Q_OBJECT
public:
explicit AddPodcastPage(Application *app, QWidget *parent = nullptr);
PodcastDiscoveryModel *model() const { return model_; }
virtual bool has_visible_widget() const { return true; }
virtual void Show() {}
signals:
void Busy(bool busy);
protected:
void SetModel(PodcastDiscoveryModel *model);
private:
PodcastDiscoveryModel *model_;
};
#endif // ADDPODCASTPAGE_H

View File

@@ -0,0 +1,49 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QWidget>
#include "core/utilities.h"
#include "episodeinfowidget.h"
#include "ui_episodeinfowidget.h"
EpisodeInfoWidget::EpisodeInfoWidget(QWidget *parent)
: QWidget(parent), ui_(new Ui_EpisodeInfoWidget), app_(nullptr) {
ui_->setupUi(this);
}
EpisodeInfoWidget::~EpisodeInfoWidget() { delete ui_; }
void EpisodeInfoWidget::SetApplication(Application *app) { app_ = app; }
void EpisodeInfoWidget::SetEpisode(const PodcastEpisode &episode) {
episode_ = episode;
ui_->title->setText(episode.title());
ui_->description->setText(episode.description());
ui_->author->setText(episode.author());
ui_->date->setText(episode.publication_date().toString("d MMMM yyyy"));
ui_->duration->setText(Utilities::PrettyTime(episode.duration_secs()));
}

View File

@@ -0,0 +1,50 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef EPISODEINFOWIDGET_H
#define EPISODEINFOWIDGET_H
#include <QWidget>
#include "podcastepisode.h"
class Application;
class Ui_EpisodeInfoWidget;
class EpisodeInfoWidget : public QWidget {
Q_OBJECT
public:
explicit EpisodeInfoWidget(QWidget *parent = nullptr);
~EpisodeInfoWidget();
void SetApplication(Application *app);
void SetEpisode(const PodcastEpisode &episode);
private:
Ui_EpisodeInfoWidget *ui_;
Application *app_;
PodcastEpisode episode_;
};
#endif // EPISODEINFOWIDGET_H

View File

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

View File

@@ -0,0 +1,82 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QString>
#include <QUrl>
#include <QIcon>
#include <QMessageBox>
#include "podcastdiscoverymodel.h"
#include "podcasturlloader.h"
#include "fixedopmlpage.h"
FixedOpmlPage::FixedOpmlPage(const QUrl &opml_url, const QString &title, const QIcon &icon, Application *app, QWidget *parent)
: AddPodcastPage(app, parent),
loader_(new PodcastUrlLoader(this)),
opml_url_(opml_url),
done_initial_load_(false) {
setWindowTitle(title);
setWindowIcon(icon);
}
void FixedOpmlPage::Show() {
if (!done_initial_load_) {
emit Busy(true);
done_initial_load_ = true;
PodcastUrlLoaderReply *reply = loader_->Load(opml_url_);
QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply]() { LoadFinished(reply); });
}
}
void FixedOpmlPage::LoadFinished(PodcastUrlLoaderReply *reply) {
reply->deleteLater();
emit Busy(false);
if (!reply->is_success()) {
QMessageBox::warning(this, tr("Failed to load podcast"), reply->error_text(), QMessageBox::Close);
return;
}
switch (reply->result_type()) {
case PodcastUrlLoaderReply::Type_Podcast:{
const PodcastList podcasts = reply->podcast_results();
for (const Podcast &podcast : podcasts) {
model()->appendRow(model()->CreatePodcastItem(podcast));
}
break;
}
case PodcastUrlLoaderReply::Type_Opml:
model()->CreateOpmlContainerItems(reply->opml_results(), model()->invisibleRootItem());
break;
}
}

View File

@@ -0,0 +1,56 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef FIXEDOPMLPAGE_H
#define FIXEDOPMLPAGE_H
#include <QObject>
#include <QUrl>
#include <QIcon>
#include "addpodcastpage.h"
class Application;
class PodcastUrlLoader;
class PodcastUrlLoaderReply;
class FixedOpmlPage : public AddPodcastPage {
Q_OBJECT
public:
FixedOpmlPage(const QUrl &opml_url, const QString &title, const QIcon &icon, Application *app, QWidget *parent = nullptr);
bool has_visible_widget() const { return false; }
void Show();
private slots:
void LoadFinished(PodcastUrlLoaderReply *reply);
private:
PodcastUrlLoader *loader_;
QUrl opml_url_;
bool done_initial_load_;
};
#endif // FIXEDOPMLPAGE_H

View File

@@ -0,0 +1,100 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QMessageBox>
#include <QPushButton>
#include "core/application.h"
#include "core/iconloader.h"
#include "core/networkaccessmanager.h"
#include "podcast.h"
#include "podcastdiscoverymodel.h"
#include "gpoddersearchpage.h"
#include "ui_gpoddersearchpage.h"
GPodderSearchPage::GPodderSearchPage(Application *app, QWidget *parent)
: AddPodcastPage(app, parent),
ui_(new Ui_GPodderSearchPage),
network_(new NetworkAccessManager(this)),
api_(new mygpo::ApiRequest(network_)) {
ui_->setupUi(this);
QObject::connect(ui_->search, &QPushButton::clicked, this, &GPodderSearchPage::SearchClicked);
setWindowIcon(IconLoader::Load("mygpo"));
}
GPodderSearchPage::~GPodderSearchPage() {
delete ui_;
delete api_;
}
void GPodderSearchPage::SearchClicked() {
emit Busy(true);
mygpo::PodcastListPtr list(api_->search(ui_->query->text()));
QObject::connect(list.data(), &mygpo::PodcastList::finished, this, [this, list]() { SearchFinished(list); });
QObject::connect(list.data(), &mygpo::PodcastList::parseError, this, [this, list]() { SearchFailed(list); });
QObject::connect(list.data(), &mygpo::PodcastList::requestError, this, [this, list]() { SearchFailed(list); });
}
void GPodderSearchPage::SearchFinished(mygpo::PodcastListPtr list) {
emit Busy(false);
model()->clear();
for (mygpo::PodcastPtr gpo_podcast : list->list()) {
Podcast podcast;
podcast.InitFromGpo(gpo_podcast.data());
model()->appendRow(model()->CreatePodcastItem(podcast));
}
}
void GPodderSearchPage::SearchFailed(mygpo::PodcastListPtr list) {
emit Busy(false);
model()->clear();
if (QMessageBox::warning(
nullptr, tr("Failed to fetch podcasts"),
tr("There was a problem communicating with gpodder.net"),
QMessageBox::Retry | QMessageBox::Close,
QMessageBox::Retry) != QMessageBox::Retry) {
return;
}
// Try doing the search again.
SearchClicked();
}
void GPodderSearchPage::Show() { ui_->query->setFocus(); }

View File

@@ -0,0 +1,57 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERSEARCHPAGE_H
#define GPODDERSEARCHPAGE_H
#include <ApiRequest.h>
#include "addpodcastpage.h"
class QNetworkAccessManager;
class Application;
class Ui_GPodderSearchPage;
class GPodderSearchPage : public AddPodcastPage {
Q_OBJECT
public:
explicit GPodderSearchPage(Application *app, QWidget *parent = nullptr);
~GPodderSearchPage();
void Show();
private slots:
void SearchClicked();
void SearchFinished(mygpo::PodcastListPtr list);
void SearchFailed(mygpo::PodcastListPtr list);
private:
Ui_GPodderSearchPage *ui_;
QNetworkAccessManager *network_;
mygpo::ApiRequest *api_;
};
#endif // GPODDERSEARCHPAGE_H

View File

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

View File

@@ -0,0 +1,415 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QCoreApplication>
#include <QSet>
#include <QList>
#include <QMap>
#include <QString>
#include <QUrl>
#include <QHostInfo>
#include <QNetworkReply>
#include <QNetworkCookieJar>
#include <QSettings>
#include <QTimer>
#include "core/application.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "core/timeconstants.h"
#include "core/utilities.h"
#include "podcastbackend.h"
#include "podcasturlloader.h"
#include "gpoddersync.h"
const char *GPodderSync::kSettingsGroup = "Podcasts";
const int GPodderSync::kFlushUpdateQueueDelay = 30 * kMsecPerSec; // 30 seconds
const int GPodderSync::kGetUpdatesInterval = 30 * 60 * kMsecPerSec; // 30 minutes
const int GPodderSync::kRequestTimeout = 30 * kMsecPerSec; // 30 seconds
GPodderSync::GPodderSync(Application *app, QObject *parent)
: QObject(parent),
app_(app),
network_(new NetworkAccessManager(this)),
backend_(app_->podcast_backend()),
loader_(new PodcastUrlLoader(this)),
get_updates_timer_(new QTimer(this)),
flush_queue_timer_(new QTimer(this)),
flushing_queue_(false) {
ReloadSettings();
LoadQueue();
QObject::connect(app_, &Application::SettingsChanged, this, &GPodderSync::ReloadSettings);
QObject::connect(backend_, &PodcastBackend::SubscriptionAdded, this, &GPodderSync::SubscriptionAdded);
QObject::connect(backend_, &PodcastBackend::SubscriptionRemoved, this, &GPodderSync::SubscriptionRemoved);
get_updates_timer_->setInterval(kGetUpdatesInterval);
connect(get_updates_timer_, &QTimer::timeout, this, &GPodderSync::GetUpdatesNow);
flush_queue_timer_->setInterval(kFlushUpdateQueueDelay);
flush_queue_timer_->setSingleShot(true);
QObject::connect(flush_queue_timer_, &QTimer::timeout, this, &GPodderSync::FlushUpdateQueue);
if (is_logged_in()) {
GetUpdatesNow();
flush_queue_timer_->start();
get_updates_timer_->start();
}
}
GPodderSync::~GPodderSync() {}
QString GPodderSync::DeviceId() {
return QString("%1-%2").arg(qApp->applicationName(), QHostInfo::localHostName()).toLower();
}
QString GPodderSync::DefaultDeviceName() {
return tr("%1 on %2").arg(qApp->applicationName(), QHostInfo::localHostName());
}
bool GPodderSync::is_logged_in() const {
return !username_.isEmpty() && !password_.isEmpty() && api_;
}
void GPodderSync::ReloadSettings() {
QSettings s;
s.beginGroup(kSettingsGroup);
username_ = s.value("gpodder_username").toString();
password_ = s.value("gpodder_password").toString();
last_successful_get_ = s.value("gpodder_last_get").toDateTime();
s.endGroup();
if (!username_.isEmpty() && !password_.isEmpty()) {
api_.reset(new mygpo::ApiRequest(username_, password_, network_));
}
}
void GPodderSync::Login(const QString &username, const QString &password, const QString &device_name) {
api_.reset(new mygpo::ApiRequest(username, password, network_));
QNetworkReply *reply = api_->renameDevice(username, DeviceId(), device_name, mygpo::Device::DESKTOP);
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, username, password]() { LoginFinished(reply, username, password); });
}
void GPodderSync::LoginFinished(QNetworkReply *reply, const QString &username, const QString &password) {
reply->deleteLater();
if (reply->error() == QNetworkReply::NoError) {
username_ = username;
password_ = password;
QSettings s;
s.beginGroup(kSettingsGroup);
s.setValue("gpodder_username", username);
s.setValue("gpodder_password", password);
s.endGroup();
DoInitialSync();
emit LoginSuccess();
}
else {
api_.reset();
emit LoginFailure(reply->errorString());
}
}
void GPodderSync::Logout() {
QSettings s;
s.beginGroup(kSettingsGroup);
s.remove("gpodder_username");
s.remove("gpodder_password");
s.remove("gpodder_last_get");
s.endGroup();
api_.reset();
// Remove session cookies. QNetworkAccessManager takes ownership of the new object and frees the previous.
network_->setCookieJar(new QNetworkCookieJar());
}
void GPodderSync::GetUpdatesNow() {
if (!is_logged_in()) return;
qlonglong timestamp = 0;
if (last_successful_get_.isValid()) {
timestamp = last_successful_get_.toSecsSinceEpoch();
}
mygpo::DeviceUpdatesPtr reply(api_->deviceUpdates(username_, DeviceId(), timestamp));
QObject::connect(reply.data(), &mygpo::DeviceUpdates::finished, this, [this, reply]() { DeviceUpdatesFinished(reply); });
QObject::connect(reply.data(), &mygpo::DeviceUpdates::parseError, this, &GPodderSync::DeviceUpdatesParseError);
QObject::connect(reply.data(), &mygpo::DeviceUpdates::requestError, this, &GPodderSync::DeviceUpdatesRequestError);
}
void GPodderSync::DeviceUpdatesParseError() {
qLog(Warning) << "Failed to get gpodder device updates: parse error";
}
void GPodderSync::DeviceUpdatesRequestError(QNetworkReply::NetworkError error) {
qLog(Warning) << "Failed to get gpodder device updates:" << error;
}
void GPodderSync::DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr reply) {
// Remember episode actions for each podcast, so when we add a new podcast
// we can apply the actions immediately.
QMap<QUrl, QList<mygpo::EpisodePtr>> episodes_by_podcast;
for (mygpo::EpisodePtr episode : reply->updateList()) {
episodes_by_podcast[episode->podcastUrl()].append(episode);
}
for (mygpo::PodcastPtr podcast : reply->addList()) {
const QUrl url(podcast->url());
// Are we subscribed to this podcast already?
Podcast existing_podcast = backend_->GetSubscriptionByUrl(url);
if (existing_podcast.is_valid()) {
// Just apply actions to this existing podcast
ApplyActions(episodes_by_podcast[url], existing_podcast.mutable_episodes());
backend_->UpdateEpisodes(existing_podcast.episodes());
continue;
}
// Start loading the podcast. Remember actions and apply them after we have a list of the episodes.
PodcastUrlLoaderReply *loader_reply = loader_->Load(url);
QObject::connect(loader_reply, &PodcastUrlLoaderReply::Finished, this, [this, loader_reply, url, episodes_by_podcast]() { NewPodcastLoaded(loader_reply, url, episodes_by_podcast[url]); });
}
// Unsubscribe from podcasts that were removed.
for (const QUrl &url : reply->removeList()) {
backend_->Unsubscribe(backend_->GetSubscriptionByUrl(url));
}
last_successful_get_ = QDateTime::currentDateTime();
QSettings s;
s.beginGroup(kSettingsGroup);
s.setValue("gpodder_last_get", last_successful_get_);
s.endGroup();
}
void GPodderSync::NewPodcastLoaded(PodcastUrlLoaderReply *reply, const QUrl &url, const QList<mygpo::EpisodePtr> &actions) {
reply->deleteLater();
if (!reply->is_success()) {
qLog(Warning) << "Error fetching podcast at" << url << ":" << reply->error_text();
return;
}
if (reply->result_type() != PodcastUrlLoaderReply::Type_Podcast) {
qLog(Warning) << "The URL" << url << "no longer contains a podcast";
return;
}
// Apply the actions to the episodes in the podcast.
for (Podcast podcast : reply->podcast_results()) {
ApplyActions(actions, podcast.mutable_episodes());
// Add the subscription
backend_->Subscribe(&podcast);
}
}
void GPodderSync::ApplyActions(const QList<QSharedPointer<mygpo::Episode>> &actions, PodcastEpisodeList *episodes) {
for (PodcastEpisodeList::iterator it = episodes->begin(); it != episodes->end(); ++it) {
// Find an action for this episode
for (mygpo::EpisodePtr action : actions) {
if (action->url() != it->url()) continue;
switch (action->status()) {
case mygpo::Episode::PLAY:
case mygpo::Episode::DOWNLOAD:
it->set_listened(true);
break;
default:
break;
}
break;
}
}
}
void GPodderSync::SubscriptionAdded(const Podcast &podcast) {
if (!is_logged_in()) return;
const QUrl &url = podcast.url();
queued_remove_subscriptions_.remove(url);
queued_add_subscriptions_.insert(url);
SaveQueue();
flush_queue_timer_->start();
}
void GPodderSync::SubscriptionRemoved(const Podcast &podcast) {
if (!is_logged_in()) return;
const QUrl &url = podcast.url();
queued_remove_subscriptions_.insert(url);
queued_add_subscriptions_.remove(url);
SaveQueue();
flush_queue_timer_->start();
}
namespace {
template<typename T>
void WriteContainer(const T &container, QSettings *s, const char *array_name, const char *item_name) {
s->beginWriteArray(array_name, container.count());
int index = 0;
for (const auto &item : container) {
s->setArrayIndex(index++);
s->setValue(item_name, item);
}
s->endArray();
}
template<typename T>
void ReadContainer(T *container, QSettings *s, const char *array_name, const char *item_name) {
container->clear();
const int count = s->beginReadArray(array_name);
for (int i = 0; i < count; ++i) {
s->setArrayIndex(i);
*container << s->value(item_name).value<typename T::value_type>();
}
s->endArray();
}
} // namespace
void GPodderSync::SaveQueue() {
QSettings s;
s.beginGroup(kSettingsGroup);
WriteContainer(queued_add_subscriptions_, &s, "gpodder_queued_add_subscriptions", "url");
WriteContainer(queued_remove_subscriptions_, &s, "gpodder_queued_remove_subscriptions", "url");
s.endGroup();
}
void GPodderSync::LoadQueue() {
QSettings s;
s.beginGroup(kSettingsGroup);
ReadContainer(&queued_add_subscriptions_, &s, "gpodder_queued_add_subscriptions", "url");
ReadContainer(&queued_remove_subscriptions_, &s, "gpodder_queued_remove_subscriptions", "url");
s.endGroup();
}
void GPodderSync::FlushUpdateQueue() {
if (!is_logged_in() || flushing_queue_) return;
QSet<QUrl> all_urls = queued_add_subscriptions_ + queued_remove_subscriptions_;
if (all_urls.isEmpty()) return;
flushing_queue_ = true;
mygpo::AddRemoveResultPtr reply(api_->addRemoveSubscriptions(username_, DeviceId(), queued_add_subscriptions_.values(), queued_remove_subscriptions_.values()));
qLog(Info) << "Sending" << all_urls.count() << "changes to gpodder.net";
QObject::connect(reply.data(), &mygpo::AddRemoveResult::finished, this, [this, all_urls]() { AddRemoveFinished(all_urls.values()); });
QObject::connect(reply.data(), &mygpo::AddRemoveResult::parseError, this, &GPodderSync::AddRemoveParseError);
QObject::connect(reply.data(), &mygpo::AddRemoveResult::requestError, this, &GPodderSync::AddRemoveRequestError);
}
void GPodderSync::AddRemoveParseError() {
flushing_queue_ = false;
qLog(Warning) << "Failed to update gpodder subscriptions: parse error";
}
void GPodderSync::AddRemoveRequestError(QNetworkReply::NetworkError err) {
flushing_queue_ = false;
qLog(Warning) << "Failed to update gpodder subscriptions:" << err;
}
void GPodderSync::AddRemoveFinished(const QList<QUrl> &affected_urls) {
flushing_queue_ = false;
// Remove the URLs from the queue.
for (const QUrl &url : affected_urls) {
queued_add_subscriptions_.remove(url);
queued_remove_subscriptions_.remove(url);
}
SaveQueue();
// Did more change in the mean time?
if (!queued_add_subscriptions_.isEmpty() ||
!queued_remove_subscriptions_.isEmpty()) {
flush_queue_timer_->start();
}
}
void GPodderSync::DoInitialSync() {
// Get updates from the server
GetUpdatesNow();
get_updates_timer_->start();
// Send our complete list of subscriptions
queued_remove_subscriptions_.clear();
queued_add_subscriptions_.clear();
for (const Podcast &podcast : backend_->GetAllSubscriptions()) {
queued_add_subscriptions_.insert(podcast.url());
}
SaveQueue();
FlushUpdateQueue();
}

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

@@ -0,0 +1,125 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERSYNC_H
#define GPODDERSYNC_H
#include <QObject>
#include <QScopedPointer>
#include <QSet>
#include <QList>
#include <QString>
#include <QDateTime>
#include <QUrl>
#include <QNetworkReply>
#include <ApiRequest.h>
#include "podcastepisode.h"
class QTimer;
class Application;
class NetworkAccessManager;
class Podcast;
class PodcastBackend;
class PodcastUrlLoader;
class PodcastUrlLoaderReply;
class GPodderSync : public QObject {
Q_OBJECT
public:
explicit GPodderSync(Application *app, QObject *parent = nullptr);
~GPodderSync();
static const char *kSettingsGroup;
static const int kFlushUpdateQueueDelay;
static const int kGetUpdatesInterval;
static const int kRequestTimeout;
static QString DefaultDeviceName();
static QString DeviceId();
bool is_logged_in() const;
// Tries to login using the given username and password. Also sets the device name and type on gpodder.net.
// If login succeeds the username and password will be saved in QSettings.
void Login(const QString &username, const QString &password, const QString &device_name);
// Clears any saved username and password from QSettings.
void Logout();
signals:
void LoginSuccess();
void LoginFailure(const QString &error);
public slots:
void GetUpdatesNow();
private slots:
void ReloadSettings();
void LoginFinished(QNetworkReply *reply, const QString &username, const QString &password);
void DeviceUpdatesFinished(mygpo::DeviceUpdatesPtr reply);
void DeviceUpdatesParseError();
void DeviceUpdatesRequestError(QNetworkReply::NetworkError error);
void NewPodcastLoaded(PodcastUrlLoaderReply *reply, const QUrl &url, const QList<mygpo::EpisodePtr> &actions);
void ApplyActions(const QList<mygpo::EpisodePtr> &actions, PodcastEpisodeList *episodes);
void SubscriptionAdded(const Podcast &podcast);
void SubscriptionRemoved(const Podcast &podcast);
void FlushUpdateQueue();
void AddRemoveFinished(const QList<QUrl> &affected_urls);
void AddRemoveParseError();
void AddRemoveRequestError(QNetworkReply::NetworkError error);
private:
void LoadQueue();
void SaveQueue();
void DoInitialSync();
private:
Application *app_;
NetworkAccessManager *network_;
QScopedPointer<mygpo::ApiRequest> api_;
PodcastBackend *backend_;
PodcastUrlLoader *loader_;
QString username_;
QString password_;
QDateTime last_successful_get_;
QTimer *get_updates_timer_;
QTimer *flush_queue_timer_;
QSet<QUrl> queued_add_subscriptions_;
QSet<QUrl> queued_remove_subscriptions_;
bool flushing_queue_;
};
#endif // GPODDERSYNC_H

View File

@@ -0,0 +1,115 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QMessageBox>
#include <ApiRequest.h>
#include "core/application.h"
#include "gpoddertoptagsmodel.h"
#include "gpoddertoptagspage.h"
#include "podcast.h"
GPodderTopTagsModel::GPodderTopTagsModel(mygpo::ApiRequest *api, Application *app, QObject *parent)
: PodcastDiscoveryModel(app, parent), api_(api) {}
bool GPodderTopTagsModel::hasChildren(const QModelIndex &parent) const {
if (parent.isValid() && parent.data(Role_Type).toInt() == Type_Folder) {
return true;
}
return PodcastDiscoveryModel::hasChildren(parent);
}
bool GPodderTopTagsModel::canFetchMore(const QModelIndex &parent) const {
if (parent.isValid() && parent.data(Role_Type).toInt() == Type_Folder &&
!parent.data(Role_HasLazyLoaded).toBool()) {
return true;
}
return PodcastDiscoveryModel::canFetchMore(parent);
}
void GPodderTopTagsModel::fetchMore(const QModelIndex &parent) {
if (!parent.isValid() || parent.data(Role_Type).toInt() != Type_Folder ||
parent.data(Role_HasLazyLoaded).toBool()) {
return;
}
setData(parent, true, Role_HasLazyLoaded);
// Create a little Loading... item.
itemFromIndex(parent)->appendRow(CreateLoadingIndicator());
mygpo::PodcastListPtr list(api_->podcastsOfTag(GPodderTopTagsPage::kMaxTagCount, parent.data().toString()));
QObject::connect(list.get(), &mygpo::PodcastList::finished, this, [this, parent, list]() { PodcastsOfTagFinished(parent, list.data()); });
QObject::connect(list.get(), &mygpo::PodcastList::parseError, this, [this, parent, list]() { PodcastsOfTagFailed(parent, list.data()); });
QObject::connect(list.get(), &mygpo::PodcastList::requestError, this, [this, parent, list]() { PodcastsOfTagFailed(parent, list.data()); });
}
void GPodderTopTagsModel::PodcastsOfTagFinished(const QModelIndex &parent, mygpo::PodcastList *list) {
QStandardItem *parent_item = itemFromIndex(parent);
if (!parent_item) return;
// Remove the Loading... item.
while (parent_item->hasChildren()) {
parent_item->removeRow(0);
}
for (mygpo::PodcastPtr gpo_podcast : list->list()) {
Podcast podcast;
podcast.InitFromGpo(gpo_podcast.data());
parent_item->appendRow(CreatePodcastItem(podcast));
}
}
void GPodderTopTagsModel::PodcastsOfTagFailed(const QModelIndex &parent, mygpo::PodcastList*) {
QStandardItem *parent_item = itemFromIndex(parent);
if (!parent_item) return;
// Remove the Loading... item.
while (parent_item->hasChildren()) {
parent_item->removeRow(0);
}
if (QMessageBox::warning(nullptr, tr("Failed to fetch podcasts"), tr("There was a problem communicating with gpodder.net"), QMessageBox::Retry | QMessageBox::Close, QMessageBox::Retry) != QMessageBox::Retry) {
return;
}
// Try fetching the list again.
setData(parent, false, Role_HasLazyLoaded);
fetchMore(parent);
}

View File

@@ -0,0 +1,61 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERTOPTAGSMODEL_H
#define GPODDERTOPTAGSMODEL_H
#include <QObject>
#include "podcastdiscoverymodel.h"
namespace mygpo {
class ApiRequest;
class PodcastList;
} // namespace mygpo
class Application;
class GPodderTopTagsModel : public PodcastDiscoveryModel {
Q_OBJECT
public:
GPodderTopTagsModel(mygpo::ApiRequest *api, Application *app, QObject *parent = nullptr);
enum Role {
Role_HasLazyLoaded = PodcastDiscoveryModel::RoleCount,
RoleCount
};
bool hasChildren(const QModelIndex &parent) const;
bool canFetchMore(const QModelIndex &parent) const;
void fetchMore(const QModelIndex &parent);
private slots:
void PodcastsOfTagFinished(const QModelIndex &parent, mygpo::PodcastList *list);
void PodcastsOfTagFailed(const QModelIndex &parent, mygpo::PodcastList *list);
private:
mygpo::ApiRequest *api_;
};
#endif // GPODDERTOPTAGSMODEL_H

View File

@@ -0,0 +1,93 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QMessageBox>
#include "TagList.h"
#include "core/application.h"
#include "core/networkaccessmanager.h"
#include "core/iconloader.h"
#include "gpoddertoptagsmodel.h"
#include "gpoddertoptagspage.h"
const int GPodderTopTagsPage::kMaxTagCount = 100;
GPodderTopTagsPage::GPodderTopTagsPage(Application *app, QWidget *parent)
: AddPodcastPage(app, parent),
network_(new NetworkAccessManager(this)),
api_(new mygpo::ApiRequest(network_)),
done_initial_load_(false) {
setWindowTitle(tr("gpodder.net directory"));
setWindowIcon(IconLoader::Load("mygpo"));
SetModel(new GPodderTopTagsModel(api_, app, this));
}
GPodderTopTagsPage::~GPodderTopTagsPage() { delete api_; }
void GPodderTopTagsPage::Show() {
if (!done_initial_load_) {
// Start the request for list of top-level tags
emit Busy(true);
done_initial_load_ = true;
mygpo::TagListPtr tag_list(api_->topTags(kMaxTagCount));
QObject::connect(tag_list.get(), &mygpo::TagList::finished, this, [this, tag_list]() { TagListLoaded(tag_list); });
QObject::connect(tag_list.get(), &mygpo::TagList::parseError, this, [this]() { TagListFailed(); });
QObject::connect(tag_list.get(), &mygpo::TagList::requestError, this, [this]() { TagListFailed(); });
}
}
void GPodderTopTagsPage::TagListLoaded(mygpo::TagListPtr tag_list) {
emit Busy(false);
for (mygpo::TagPtr tag : tag_list->list()) {
model()->appendRow(model()->CreateFolder(tag->tag()));
}
}
void GPodderTopTagsPage::TagListFailed() {
emit Busy(false);
done_initial_load_ = false;
if (QMessageBox::warning(
nullptr, tr("Failed to fetch directory"),
tr("There was a problem communicating with gpodder.net"),
QMessageBox::Retry | QMessageBox::Close,
QMessageBox::Retry) != QMessageBox::Retry) {
return;
}
// Try doing the search again.
Show();
}

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef GPODDERTOPTAGSPAGE_H
#define GPODDERTOPTAGSPAGE_H
#include <QObject>
#include <ApiRequest.h>
#include "addpodcastpage.h"
class Application;
class NetworkAccessManager;
class GPodderTopTagsPage : public AddPodcastPage {
Q_OBJECT
public:
explicit GPodderTopTagsPage(Application *app, QWidget *parent = nullptr);
~GPodderTopTagsPage();
static const int kMaxTagCount;
virtual bool has_visible_widget() const { return false; }
virtual void Show();
private slots:
void TagListLoaded(mygpo::TagListPtr tag_list);
void TagListFailed();
private:
NetworkAccessManager *network_;
mygpo::ApiRequest *api_;
bool done_initial_load_;
};
#endif // GPODDERTOPTAGSPAGE_H

View File

@@ -0,0 +1,133 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QJsonValue>
#include <QJsonObject>
#include <QJsonArray>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QUrl>
#include <QUrlQuery>
#include <QMessageBox>
#include <QPushButton>
#include "core/networkaccessmanager.h"
#include "core/iconloader.h"
#include "podcast.h"
#include "podcastdiscoverymodel.h"
#include "itunessearchpage.h"
#include "ui_itunessearchpage.h"
const char* ITunesSearchPage::kUrlBase = "http://ax.phobos.apple.com.edgesuite.net/WebObjects/MZStoreServices.woa/wa/wsSearch?country=US&media=podcast";
ITunesSearchPage::ITunesSearchPage(Application* app, QWidget* parent)
: AddPodcastPage(app, parent),
ui_(new Ui_ITunesSearchPage),
network_(new NetworkAccessManager(this)) {
ui_->setupUi(this);
QObject::connect(ui_->search, &QPushButton::clicked, this, &ITunesSearchPage::SearchClicked);
setWindowIcon(IconLoader::Load("itunes"));
}
ITunesSearchPage::~ITunesSearchPage() { delete ui_; }
void ITunesSearchPage::SearchClicked() {
emit Busy(true);
QUrl url(QUrl::fromEncoded(kUrlBase));
QUrlQuery url_query;
url_query.addQueryItem("term", ui_->query->text());
url.setQuery(url_query);
QNetworkReply *reply = network_->get(QNetworkRequest(url));
QObject::connect(reply, &QNetworkReply::finished, this, [this, reply]() { SearchFinished(reply); });
}
void ITunesSearchPage::SearchFinished(QNetworkReply* reply) {
reply->deleteLater();
emit Busy(false);
model()->clear();
// Was there a network error?
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::warning(this, tr("Failed to fetch podcasts"), reply->errorString());
return;
}
QJsonParseError error;
QJsonDocument json_document = QJsonDocument::fromJson(reply->readAll(), &error);
if (error.error != QJsonParseError::NoError) {
QMessageBox::warning(this, tr("Failed to fetch podcasts"), tr("There was a problem parsing the response from the iTunes Store"));
return;
}
QJsonObject json_data = json_document.object();
// Was there an error message in the JSON?
if (json_data.contains("errorMessage")) {
QMessageBox::warning(this, tr("Failed to fetch podcasts"), json_data["errorMessage"].toString());
return;
}
QJsonArray array = json_data["results"].toArray();
for (const QJsonValueRef &result : array) {
if (!result.isObject()) continue;
QJsonObject json_result = result.toObject();
if (json_result["kind"].toString() != "podcast") {
continue;
}
if (!json_result.contains("artistName") ||
!json_result.contains("trackName") ||
!json_result.contains("feedUrl") ||
!json_result.contains("trackViewUrl") ||
!json_result.contains("artworkUrl30") ||
!json_result.contains("artworkUrl100")) {
continue;
}
Podcast podcast;
podcast.set_author(json_result["artistName"].toString());
podcast.set_title(json_result["trackName"].toString());
podcast.set_url(QUrl(json_result["feedUrl"].toString()));
podcast.set_link(QUrl(json_result["trackViewUrl"].toString()));
podcast.set_image_url_small(QUrl(json_result["artworkUrl30"].toString()));
podcast.set_image_url_large(QUrl(json_result["artworkUrl100"].toString()));
model()->appendRow(model()->CreatePodcastItem(podcast));
}
}
void ITunesSearchPage::Show() { ui_->query->setFocus(); }

View File

@@ -0,0 +1,56 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef ITUNESSEARCHPAGE_H
#define ITUNESSEARCHPAGE_H
#include "addpodcastpage.h"
class Ui_ITunesSearchPage;
class QNetworkReply;
class NetworkAccessManager;
class ITunesSearchPage : public AddPodcastPage {
Q_OBJECT
public:
ITunesSearchPage(Application *app, QWidget *parent);
~ITunesSearchPage();
void Show();
private slots:
void SearchClicked();
void SearchFinished(QNetworkReply *reply);
private:
static const char *kUrlBase;
Ui_ITunesSearchPage *ui_;
NetworkAccessManager *network_;
};
#endif // ITUNESSEARCHPAGE_H

View File

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

View File

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

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

@@ -0,0 +1,194 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QSharedData>
#include <QVariant>
#include <QVariantMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDataStream>
#include <QDateTime>
#include <QSqlQuery>
#include "core/utilities.h"
#include "podcast.h"
#include <Podcast.h>
const QStringList Podcast::kColumns = QStringList() << "url"
<< "title"
<< "description"
<< "copyright"
<< "link"
<< "image_url_large"
<< "image_url_small"
<< "author"
<< "owner_name"
<< "owner_email"
<< "last_updated"
<< "last_update_error"
<< "extra";
const QString Podcast::kColumnSpec = Podcast::kColumns.join(", ");
const QString Podcast::kJoinSpec = Utilities::Prepend("p.", Podcast::kColumns).join(", ");
const QString Podcast::kBindSpec = Utilities::Prepend(":", Podcast::kColumns).join(", ");
const QString Podcast::kUpdateSpec = Utilities::Updateify(Podcast::kColumns).join(", ");
struct Podcast::Private : public QSharedData {
Private();
int database_id_;
QUrl url_;
QString title_;
QString description_;
QString copyright_;
QUrl link_;
QUrl image_url_large_;
QUrl image_url_small_;
// iTunes extensions
QString author_;
QString owner_name_;
QString owner_email_;
QDateTime last_updated_;
QString last_update_error_;
QVariantMap extra_;
// These are stored in a different table
PodcastEpisodeList episodes_;
};
Podcast::Private::Private() : database_id_(-1) {}
Podcast::Podcast() : d(new Private) {}
Podcast::Podcast(const Podcast &other) : d(other.d) {}
Podcast::~Podcast() {}
Podcast &Podcast::operator=(const Podcast &other) {
d = other.d;
return *this;
}
int Podcast::database_id() const { return d->database_id_; }
const QUrl &Podcast::url() const { return d->url_; }
const QString &Podcast::title() const { return d->title_; }
const QString &Podcast::description() const { return d->description_; }
const QString &Podcast::copyright() const { return d->copyright_; }
const QUrl &Podcast::link() const { return d->link_; }
const QUrl &Podcast::image_url_large() const { return d->image_url_large_; }
const QUrl &Podcast::image_url_small() const { return d->image_url_small_; }
const QString &Podcast::author() const { return d->author_; }
const QString &Podcast::owner_name() const { return d->owner_name_; }
const QString &Podcast::owner_email() const { return d->owner_email_; }
const QDateTime &Podcast::last_updated() const { return d->last_updated_; }
const QString &Podcast::last_update_error() const {
return d->last_update_error_;
}
const QVariantMap &Podcast::extra() const { return d->extra_; }
QVariant Podcast::extra(const QString &key) const { return d->extra_[key]; }
void Podcast::set_database_id(const int v) { d->database_id_ = v; }
void Podcast::set_url(const QUrl &v) { d->url_ = v; }
void Podcast::set_title(const QString &v) { d->title_ = v; }
void Podcast::set_description(const QString &v) { d->description_ = v; }
void Podcast::set_copyright(const QString &v) { d->copyright_ = v; }
void Podcast::set_link(const QUrl &v) { d->link_ = v; }
void Podcast::set_image_url_large(const QUrl &v) { d->image_url_large_ = v; }
void Podcast::set_image_url_small(const QUrl &v) { d->image_url_small_ = v; }
void Podcast::set_author(const QString &v) { d->author_ = v; }
void Podcast::set_owner_name(const QString &v) { d->owner_name_ = v; }
void Podcast::set_owner_email(const QString &v) { d->owner_email_ = v; }
void Podcast::set_last_updated(const QDateTime &v) { d->last_updated_ = v; }
void Podcast::set_last_update_error(const QString &v) { d->last_update_error_ = v; }
void Podcast::set_extra(const QVariantMap &v) { d->extra_ = v; }
void Podcast::set_extra(const QString &key, const QVariant &value) { d->extra_[key] = value; }
const PodcastEpisodeList &Podcast::episodes() const { return d->episodes_; }
PodcastEpisodeList* Podcast::mutable_episodes() { return &d->episodes_; }
void Podcast::set_episodes(const PodcastEpisodeList &v) { d->episodes_ = v; }
void Podcast::add_episode(const PodcastEpisode &episode) { d->episodes_.append(episode); }
void Podcast::InitFromQuery(const QSqlQuery &query) {
d->database_id_ = query.value(0).toInt();
d->url_ = QUrl::fromEncoded(query.value(1).toByteArray());
d->title_ = query.value(2).toString();
d->description_ = query.value(3).toString();
d->copyright_ = query.value(4).toString();
d->link_ = QUrl::fromEncoded(query.value(5).toByteArray());
d->image_url_large_ = QUrl::fromEncoded(query.value(6).toByteArray());
d->image_url_small_ = QUrl::fromEncoded(query.value(7).toByteArray());
d->author_ = query.value(8).toString();
d->owner_name_ = query.value(9).toString();
d->owner_email_ = query.value(10).toString();
d->last_updated_ = QDateTime::fromSecsSinceEpoch(query.value(11).toUInt());
d->last_update_error_ = query.value(12).toString();
QDataStream extra_stream(query.value(13).toByteArray());
extra_stream >> d->extra_;
}
void Podcast::BindToQuery(QSqlQuery* query) const {
query->bindValue(":url", d->url_.toEncoded());
query->bindValue(":title", d->title_);
query->bindValue(":description", d->description_);
query->bindValue(":copyright", d->copyright_);
query->bindValue(":link", d->link_.toEncoded());
query->bindValue(":image_url_large", d->image_url_large_.toEncoded());
query->bindValue(":image_url_small", d->image_url_small_.toEncoded());
query->bindValue(":author", d->author_);
query->bindValue(":owner_name", d->owner_name_);
query->bindValue(":owner_email", d->owner_email_);
query->bindValue(":last_updated", d->last_updated_.toSecsSinceEpoch());
query->bindValue(":last_update_error", d->last_update_error_);
QByteArray extra;
QDataStream extra_stream(&extra, QIODevice::WriteOnly);
extra_stream << d->extra_;
query->bindValue(":extra", extra);
}
void Podcast::InitFromGpo(const mygpo::Podcast* podcast) {
d->url_ = podcast->url();
d->title_ = podcast->title();
d->description_ = podcast->description();
d->link_ = podcast->website();
d->image_url_large_ = podcast->logoUrl();
set_extra("gpodder:subscribers", podcast->subscribers());
set_extra("gpodder:subscribers_last_week", podcast->subscribersLastWeek());
set_extra("gpodder:page", podcast->mygpoUrl());
}

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

@@ -0,0 +1,114 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCAST_H
#define PODCAST_H
#include <QSharedData>
#include <QSharedDataPointer>
#include <QVariant>
#include <QVariantMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QSqlQuery>
#include "podcastepisode.h"
namespace mygpo {
class Podcast;
} // namespace mygpo
class Podcast {
public:
Podcast();
Podcast(const Podcast &other);
~Podcast();
static const QStringList kColumns;
static const QString kColumnSpec;
static const QString kJoinSpec;
static const QString kBindSpec;
static const QString kUpdateSpec;
void InitFromQuery(const QSqlQuery &query);
void InitFromGpo(const mygpo::Podcast *podcast);
void BindToQuery(QSqlQuery *query) const;
bool is_valid() const { return database_id() != -1; }
int database_id() const;
const QUrl &url() const;
const QString &title() const;
const QString &description() const;
const QString &copyright() const;
const QUrl &link() const;
const QUrl &image_url_large() const;
const QUrl &image_url_small() const;
const QString &author() const;
const QString &owner_name() const;
const QString &owner_email() const;
const QDateTime &last_updated() const;
const QString &last_update_error() const;
const QVariantMap &extra() const;
QVariant extra(const QString &key) const;
void set_database_id(const int v);
void set_url(const QUrl &v);
void set_title(const QString &v);
void set_description(const QString &v);
void set_copyright(const QString &v);
void set_link(const QUrl &v);
void set_image_url_large(const QUrl &v);
void set_image_url_small(const QUrl &v);
void set_author(const QString &v);
void set_owner_name(const QString &v);
void set_owner_email(const QString &v);
void set_last_updated(const QDateTime &v);
void set_last_update_error(const QString &v);
void set_extra(const QVariantMap &v);
void set_extra(const QString &key, const QVariant &value);
// Small images are suitable for 16x16 icons in lists. Large images are used in detailed information displays.
const QUrl &ImageUrlLarge() const { return image_url_large().isValid() ? image_url_large() : image_url_small(); }
const QUrl &ImageUrlSmall() const { return image_url_small().isValid() ? image_url_small() : image_url_large(); }
// These are stored in a different database table, and aren't loaded or persisted by InitFromQuery or BindToQuery.
const PodcastEpisodeList &episodes() const;
PodcastEpisodeList *mutable_episodes();
void set_episodes(const PodcastEpisodeList &v);
void add_episode(const PodcastEpisode &episode);
Podcast &operator=(const Podcast &other);
private:
struct Private;
QSharedDataPointer<Private> d;
};
Q_DECLARE_METATYPE(Podcast)
typedef QList<Podcast> PodcastList;
Q_DECLARE_METATYPE(QList<Podcast>)
#endif // PODCAST_H

View File

@@ -0,0 +1,368 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QMutexLocker>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QUrl>
#include "core/application.h"
#include "core/database.h"
#include "core/logging.h"
#include "core/scopedtransaction.h"
#include "podcastbackend.h"
PodcastBackend::PodcastBackend(Application *app, QObject *parent)
: QObject(parent), app_(app), db_(app->database()) {}
void PodcastBackend::Subscribe(Podcast *podcast) {
// If this podcast is already in the database, do nothing
if (podcast->is_valid()) {
return;
}
// If there's an entry in the database with the same URL, take its data.
Podcast existing_podcast = GetSubscriptionByUrl(podcast->url());
if (existing_podcast.is_valid()) {
*podcast = existing_podcast;
return;
}
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
ScopedTransaction t(&db);
// Insert the podcast.
QSqlQuery q(db);
q.prepare("INSERT INTO podcasts (" + Podcast::kColumnSpec + ") VALUES (" + Podcast::kBindSpec + ")");
podcast->BindToQuery(&q);
q.exec();
if (db_->CheckErrors(q)) return;
// Update the database ID.
const int database_id = q.lastInsertId().toInt();
podcast->set_database_id(database_id);
// Update the IDs of any episodes.
PodcastEpisodeList *episodes = podcast->mutable_episodes();
for (auto it = episodes->begin(); it != episodes->end(); ++it) {
it->set_podcast_database_id(database_id);
}
// Add those episodes to the database.
AddEpisodes(episodes, &db);
t.Commit();
emit SubscriptionAdded(*podcast);
}
void PodcastBackend::Unsubscribe(const Podcast &podcast) {
// If this podcast is not already in the database, do nothing
if (!podcast.is_valid()) {
return;
}
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
ScopedTransaction t(&db);
// Remove the podcast.
QSqlQuery q(db);
q.prepare("DELETE FROM podcasts WHERE ROWID = :id");
q.bindValue(":id", podcast.database_id());
q.exec();
if (db_->CheckErrors(q)) return;
// Remove all episodes in the podcast
q.prepare("DELETE FROM podcast_episodes WHERE podcast_id = :id");
q.bindValue(":id", podcast.database_id());
q.exec();
if (db_->CheckErrors(q)) return;
t.Commit();
emit SubscriptionRemoved(podcast);
}
void PodcastBackend::AddEpisodes(PodcastEpisodeList *episodes, QSqlDatabase *db) {
QSqlQuery q(*db);
q.prepare("INSERT INTO podcast_episodes (" + PodcastEpisode::kColumnSpec + ") VALUES (" + PodcastEpisode::kBindSpec + ")");
for (auto it = episodes->begin(); it != episodes->end(); ++it) {
it->BindToQuery(&q);
q.exec();
if (db_->CheckErrors(q)) continue;
const int database_id = q.lastInsertId().toInt();
it->set_database_id(database_id);
}
}
void PodcastBackend::AddEpisodes(PodcastEpisodeList *episodes) {
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
ScopedTransaction t(&db);
AddEpisodes(episodes, &db);
t.Commit();
emit EpisodesAdded(*episodes);
}
void PodcastBackend::UpdateEpisodes(const PodcastEpisodeList &episodes) {
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
ScopedTransaction t(&db);
QSqlQuery q(db);
q.prepare("UPDATE podcast_episodes SET listened = :listened, listened_date = :listened_date, downloaded = :downloaded, local_url = :local_url WHERE ROWID = :id");
for (const PodcastEpisode &episode : episodes) {
q.bindValue(":listened", episode.listened());
q.bindValue(":listened_date", episode.listened_date().toSecsSinceEpoch());
q.bindValue(":downloaded", episode.downloaded());
q.bindValue(":local_url", episode.local_url().toEncoded());
q.bindValue(":id", episode.database_id());
q.exec();
db_->CheckErrors(q);
}
t.Commit();
emit EpisodesUpdated(episodes);
}
PodcastList PodcastBackend::GetAllSubscriptions() {
PodcastList ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts");
q.exec();
if (db_->CheckErrors(q)) return ret;
while (q.next()) {
Podcast podcast;
podcast.InitFromQuery(q);
ret << podcast;
}
return ret;
}
Podcast PodcastBackend::GetSubscriptionById(const int id) {
Podcast ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts WHERE ROWID = :id");
q.bindValue(":id", id);
q.exec();
if (!db_->CheckErrors(q) && q.next()) {
ret.InitFromQuery(q);
}
return ret;
}
Podcast PodcastBackend::GetSubscriptionByUrl(const QUrl &url) {
Podcast ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + Podcast::kColumnSpec + " FROM podcasts WHERE url = :url");
q.bindValue(":url", url.toEncoded());
q.exec();
if (!db_->CheckErrors(q) && q.next()) {
ret.InitFromQuery(q);
}
return ret;
}
PodcastEpisodeList PodcastBackend::GetEpisodes(const int podcast_id) {
PodcastEpisodeList ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE podcast_id = :id ORDER BY publication_date DESC");
q.bindValue(":id", podcast_id);
q.exec();
if (db_->CheckErrors(q)) return ret;
while (q.next()) {
PodcastEpisode episode;
episode.InitFromQuery(q);
ret << episode;
}
return ret;
}
PodcastEpisode PodcastBackend::GetEpisodeById(const int id) {
PodcastEpisode ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE ROWID = :id");
q.bindValue(":id", id);
q.exec();
if (!db_->CheckErrors(q) && q.next()) {
ret.InitFromQuery(q);
}
return ret;
}
PodcastEpisode PodcastBackend::GetEpisodeByUrl(const QUrl &url) {
PodcastEpisode ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE url = :url");
q.bindValue(":url", url.toEncoded());
q.exec();
if (!db_->CheckErrors(q) && q.next()) {
ret.InitFromQuery(q);
}
return ret;
}
PodcastEpisode PodcastBackend::GetEpisodeByUrlOrLocalUrl(const QUrl &url) {
PodcastEpisode ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE url = :url OR local_url = :url");
q.bindValue(":url", url.toEncoded());
q.exec();
if (!db_->CheckErrors(q) && q.next()) {
ret.InitFromQuery(q);
}
return ret;
}
PodcastEpisodeList PodcastBackend::GetOldDownloadedEpisodes(const QDateTime &max_listened_date) {
PodcastEpisodeList ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE downloaded = 'true' AND listened_date <= :max_listened_date");
q.bindValue(":max_listened_date", max_listened_date.toSecsSinceEpoch());
q.exec();
if (db_->CheckErrors(q)) return ret;
while (q.next()) {
PodcastEpisode episode;
episode.InitFromQuery(q);
ret << episode;
}
return ret;
}
PodcastEpisode PodcastBackend::GetOldestDownloadedListenedEpisode() {
PodcastEpisode ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE downloaded = 'true' AND listened = 'true' ORDER BY listened_date ASC");
q.exec();
if (db_->CheckErrors(q)) return ret;
q.next();
ret.InitFromQuery(q);
return ret;
}
PodcastEpisodeList PodcastBackend::GetNewDownloadedEpisodes() {
PodcastEpisodeList ret;
QMutexLocker l(db_->Mutex());
QSqlDatabase db(db_->Connect());
QSqlQuery q(db);
q.prepare("SELECT ROWID, " + PodcastEpisode::kColumnSpec + " FROM podcast_episodes WHERE downloaded = 'true' AND listened = 'false'");
q.exec();
if (db_->CheckErrors(q)) return ret;
while (q.next()) {
PodcastEpisode episode;
episode.InitFromQuery(q);
ret << episode;
}
return ret;
}

View File

@@ -0,0 +1,98 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTBACKEND_H
#define PODCASTBACKEND_H
#include <QObject>
#include <QDateTime>
#include <QUrl>
#include "podcast.h"
class QSqlDatabase;
class Application;
class Database;
class PodcastBackend : public QObject {
Q_OBJECT
public:
explicit PodcastBackend(Application *app, QObject *parent = nullptr);
// Adds the podcast and any included Episodes to the database.
// Updates the podcast with a database ID.
// If this podcast already has an ID set, this function does nothing.
// If a podcast with this URL already exists in the database,
// this function just updates the ID field in the provided podcast.
void Subscribe(Podcast *podcast);
// Removes the Podcast with the given ID from the database.
// Also removes any episodes associated with this podcast.
void Unsubscribe(const Podcast &podcast);
// Returns a list of all the subscribed podcasts.
// For efficiency the Podcast objects returned won't contain any PodcastEpisode objects - get them separately if you want them.
PodcastList GetAllSubscriptions();
Podcast GetSubscriptionById(const int id);
Podcast GetSubscriptionByUrl(const QUrl &url);
// Returns podcast episodes that match various keys. All these queries are indexed.
PodcastEpisodeList GetEpisodes(const int podcast_id);
PodcastEpisode GetEpisodeById(const int id);
PodcastEpisode GetEpisodeByUrl(const QUrl &url);
PodcastEpisode GetEpisodeByUrlOrLocalUrl(const QUrl &url);
PodcastEpisode GetOldestDownloadedListenedEpisode();
// Returns a list of episodes that have local data (downloaded=true) but were last listened to before the given QDateTime.
// This query is NOT indexed so it involves a full search of the table.
PodcastEpisodeList GetOldDownloadedEpisodes(const QDateTime &max_listened_date);
PodcastEpisodeList GetNewDownloadedEpisodes();
// Adds episodes to the database. Every episode must have a valid podcast_database_id set already.
void AddEpisodes(PodcastEpisodeList *episodes);
// Updates the editable fields (listened, listened_date, downloaded, and local_url) on episodes that must already exist in the database.
void UpdateEpisodes(const PodcastEpisodeList &episodes);
signals:
void SubscriptionAdded(const Podcast &podcast);
void SubscriptionRemoved(const Podcast &podcast);
// Emitted when episodes are added to a subscription that *already exists*.
void EpisodesAdded(const PodcastEpisodeList &episodes);
// Emitted when existing episodes are updated.
void EpisodesUpdated(const PodcastEpisodeList &episodes);
private:
// Adds each episode to the database, setting their IDs after inserting each one.
void AddEpisodes(PodcastEpisodeList *episodes, QSqlDatabase *db);
private:
Application *app_;
Database *db_;
};
#endif // PODCASTBACKEND_H

View File

@@ -0,0 +1,124 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QFile>
#include <QDateTime>
#include <QTimer>
#include <QSettings>
#include "core/application.h"
#include "core/logging.h"
#include "core/timeconstants.h"
#include "core/utilities.h"
#include "collection/collectiondirectorymodel.h"
#include "collection/collectionmodel.h"
#include "podcastbackend.h"
#include "podcastdeleter.h"
const char *PodcastDeleter::kSettingsGroup = "Podcasts";
const int PodcastDeleter::kAutoDeleteCheckIntervalMsec = 60 * 6 * 60 * kMsecPerSec;
PodcastDeleter::PodcastDeleter(Application *app, QObject *parent)
: QObject(parent),
app_(app),
backend_(app_->podcast_backend()),
delete_after_secs_(0),
auto_delete_timer_(new QTimer(this)) {
ReloadSettings();
auto_delete_timer_->setSingleShot(true);
AutoDelete();
QObject::connect(auto_delete_timer_, &QTimer::timeout, this, &PodcastDeleter::AutoDelete);
QObject::connect(app_, &Application::SettingsChanged, this, &PodcastDeleter::ReloadSettings);
}
void PodcastDeleter::DeleteEpisode(const PodcastEpisode &episode) {
// Delete the local file
if (!QFile::remove(episode.local_url().toLocalFile())) {
qLog(Warning) << "The local file" << episode.local_url().toLocalFile() << "could not be removed";
}
// Update the episode in the DB
PodcastEpisode episode_copy(episode);
episode_copy.set_downloaded(false);
episode_copy.set_local_url(QUrl());
episode_copy.set_listened_date(QDateTime());
backend_->UpdateEpisodes(PodcastEpisodeList() << episode_copy);
}
void PodcastDeleter::ReloadSettings() {
QSettings s;
s.beginGroup(kSettingsGroup);
delete_after_secs_ = s.value("delete_after", 0).toInt();
s.endGroup();
AutoDelete();
}
void PodcastDeleter::AutoDelete() {
if (delete_after_secs_ <= 0) {
return;
}
auto_delete_timer_->stop();
QDateTime max_date = QDateTime::currentDateTime();
qint64 timeout_ms;
PodcastEpisode oldest_episode;
QDateTime oldest_episode_time;
max_date = max_date.addSecs(-delete_after_secs_);
PodcastEpisodeList old_episodes = backend_->GetOldDownloadedEpisodes(max_date);
qLog(Info) << "Deleting" << old_episodes.count()
<< "episodes because they were last listened to"
<< (delete_after_secs_ / kSecsPerDay) << "days ago";
for (const PodcastEpisode& episode : old_episodes) {
DeleteEpisode(episode);
}
oldest_episode = backend_->GetOldestDownloadedListenedEpisode();
if (!oldest_episode.listened_date().isValid()) {
oldest_episode_time = QDateTime::currentDateTime();
}
else {
oldest_episode_time = oldest_episode.listened_date();
}
timeout_ms = QDateTime::currentDateTime().toMSecsSinceEpoch();
timeout_ms -= oldest_episode_time.toMSecsSinceEpoch();
timeout_ms = (delete_after_secs_ * kMsecPerSec) - timeout_ms;
qLog(Info) << "Timeout for autodelete set to:" << timeout_ms << "ms";
if (timeout_ms >= 0) {
auto_delete_timer_->setInterval(timeout_ms);
}
else {
auto_delete_timer_->setInterval(kAutoDeleteCheckIntervalMsec);
}
auto_delete_timer_->start();
}

View File

@@ -0,0 +1,56 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTDELETER_H
#define PODCASTDELETER_H
#include <QObject>
#include "podcast.h"
#include "podcastepisode.h"
class QTimer;
class Application;
class PodcastBackend;
class PodcastDeleter : public QObject {
Q_OBJECT
public:
explicit PodcastDeleter(Application *app, QObject *parent = nullptr);
static const char *kSettingsGroup;
static const int kAutoDeleteCheckIntervalMsec;
public slots:
// Deletes downloaded data for this episode
void DeleteEpisode(const PodcastEpisode &episode);
void AutoDelete();
void ReloadSettings();
private:
Application *app_;
PodcastBackend *backend_;
int delete_after_secs_;
QTimer *auto_delete_timer_;
};
#endif // PODCASTDELETER_H

View File

@@ -0,0 +1,125 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "podcastdiscoverymodel.h"
#include <QStandardItemModel>
#include <QStandardItem>
#include <QSet>
#include <QString>
#include <QUrl>
#include <QIcon>
#include "core/application.h"
#include "core/iconloader.h"
#include "core/standarditemiconloader.h"
#include "opmlcontainer.h"
#include "podcast.h"
PodcastDiscoveryModel::PodcastDiscoveryModel(Application *app, QObject *parent)
: QStandardItemModel(parent),
app_(app),
icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)),
default_icon_(IconLoader::Load("podcast")) {
icon_loader_->SetModel(this);
}
QVariant PodcastDiscoveryModel::data(const QModelIndex &idx, int role) const {
if (idx.isValid() && role == Qt::DecorationRole && !QStandardItemModel::data(idx, Role_StartedLoadingImage).toBool()) {
const QUrl image_url = QStandardItemModel::data(idx, Role_ImageUrl).toUrl();
if (image_url.isValid()) {
const_cast<PodcastDiscoveryModel*>(this)->LazyLoadImage(image_url, idx);
}
}
return QStandardItemModel::data(idx, role);
}
QStandardItem *PodcastDiscoveryModel::CreatePodcastItem(const Podcast &podcast) {
QStandardItem *item = new QStandardItem;
item->setIcon(default_icon_);
item->setText(podcast.title());
item->setData(QVariant::fromValue(podcast), Role_Podcast);
item->setData(Type_Podcast, Role_Type);
item->setData(podcast.ImageUrlSmall(), Role_ImageUrl);
return item;
}
QStandardItem *PodcastDiscoveryModel::CreateFolder(const QString &name) {
if (folder_icon_.isNull()) {
folder_icon_ = IconLoader::Load("folder");
}
QStandardItem *item = new QStandardItem;
item->setIcon(folder_icon_);
item->setText(name);
item->setData(Type_Folder, Role_Type);
return item;
}
QStandardItem *PodcastDiscoveryModel::CreateOpmlContainerItem(const OpmlContainer &container) {
QStandardItem *item = CreateFolder(container.name);
CreateOpmlContainerItems(container, item);
return item;
}
void PodcastDiscoveryModel::CreateOpmlContainerItems(const OpmlContainer &container, QStandardItem *parent) {
for (const OpmlContainer &child : container.containers) {
QStandardItem *child_item = CreateOpmlContainerItem(child);
parent->appendRow(child_item);
}
for (const Podcast &child : container.feeds) {
QStandardItem *child_item = CreatePodcastItem(child);
parent->appendRow(child_item);
}
}
void PodcastDiscoveryModel::LazyLoadImage(const QUrl &url, const QModelIndex &idx) {
QStandardItem *item = itemFromIndex(idx);
item->setData(true, Role_StartedLoadingImage);
icon_loader_->LoadIcon(url, QUrl(), item);
}
QStandardItem *PodcastDiscoveryModel::CreateLoadingIndicator() {
QStandardItem *item = new QStandardItem;
item->setText(tr("Loading..."));
item->setData(Type_LoadingIndicator, Role_Type);
return item;
}

View File

@@ -0,0 +1,79 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTDISCOVERYMODEL_H
#define PODCASTDISCOVERYMODEL_H
#include <QStandardItemModel>
#include <QString>
#include <QUrl>
#include <QIcon>
#include "covermanager/albumcoverloaderoptions.h"
class Application;
class OpmlContainer;
class OpmlFeed;
class Podcast;
class StandardItemIconLoader;
class PodcastDiscoveryModel : public QStandardItemModel {
Q_OBJECT
public:
explicit PodcastDiscoveryModel(Application *app, QObject *parent = nullptr);
enum Type {
Type_Folder,
Type_Podcast,
Type_LoadingIndicator
};
enum Role {
Role_Podcast = Qt::UserRole,
Role_Type,
Role_ImageUrl,
Role_StartedLoadingImage,
RoleCount
};
void CreateOpmlContainerItems(const OpmlContainer &container, QStandardItem *parent);
QStandardItem *CreateOpmlContainerItem(const OpmlContainer &container);
QStandardItem *CreatePodcastItem(const Podcast &podcast);
QStandardItem *CreateFolder(const QString &name);
QStandardItem *CreateLoadingIndicator();
QVariant data(const QModelIndex &idx, int role) const override;
private:
void LazyLoadImage(const QUrl &url, const QModelIndex &idx);
private:
Application *app_;
StandardItemIconLoader *icon_loader_;
QIcon default_icon_;
QIcon folder_icon_;
};
#endif // PODCASTDISCOVERYMODEL_H

View File

@@ -0,0 +1,288 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "podcastdownloader.h"
#include <QString>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QList>
#include <QString>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QSettings>
#include <QTimer>
#include "core/application.h"
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "core/tagreaderclient.h"
#include "core/timeconstants.h"
#include "core/utilities.h"
#include "podcastbackend.h"
const char *PodcastDownloader::kSettingsGroup = "Podcasts";
Task::Task(const PodcastEpisode &episode, QFile *file, PodcastBackend *backend)
: file_(file),
episode_(episode),
backend_(backend),
network_(new NetworkAccessManager(this)),
req_(QNetworkRequest(episode.url())),
reply_(network_->get(req_)) {
QObject::connect(reply_, &QNetworkReply::readyRead, this, &Task::reading);
QObject::connect(reply_, &QNetworkReply::finished, this, &Task::finishedInternal);
QObject::connect(reply_, &QNetworkReply::downloadProgress, this, &Task::downloadProgressInternal);
emit ProgressChanged(episode_, PodcastDownload::Queued, 0);
}
PodcastEpisode Task::episode() const { return episode_; }
void Task::reading() {
qint64 bytes = 0;
forever {
bytes = reply_->bytesAvailable();
if (bytes <= 0) break;
file_->write(reply_->read(bytes));
}
}
void Task::finishedPublic() {
disconnect(reply_, &QNetworkReply::readyRead, nullptr, nullptr);
disconnect(reply_, &QNetworkReply::downloadProgress, nullptr, nullptr);
disconnect(reply_, &QNetworkReply::finished, nullptr, nullptr);
emit ProgressChanged(episode_, PodcastDownload::NotDownloading, 0);
// Delete the file
file_->remove();
emit finished(this);
}
void Task::finishedInternal() {
reply_->deleteLater();
if (reply_->error() != QNetworkReply::NoError) {
qLog(Warning) << "Error downloading episode:" << reply_->errorString();
emit ProgressChanged(episode_, PodcastDownload::NotDownloading, 0);
// Delete the file
file_->remove();
emit finished(this);
reply_ = nullptr;
return;
}
qLog(Info) << "Download of" << file_->fileName() << "finished";
// Tell the database the episode has been updated. Get it from the DB again in case the listened field changed in the mean time.
PodcastEpisode episode = episode_;
episode.set_downloaded(true);
episode.set_local_url(QUrl::fromLocalFile(file_->fileName()));
backend_->UpdateEpisodes(PodcastEpisodeList() << episode);
Podcast podcast = backend_->GetSubscriptionById(episode.podcast_database_id());
Song song = episode_.ToSong(podcast);
emit ProgressChanged(episode_, PodcastDownload::Finished, 0);
// I didn't ecountered even a single podcast with a correct metadata
TagReaderClient::Instance()->SaveFileBlocking(file_->fileName(), song);
emit finished(this);
reply_ = nullptr;
}
void Task::downloadProgressInternal(qint64 received, qint64 total) {
if (total <= 0) {
emit ProgressChanged(episode_, PodcastDownload::Downloading, 0);
}
else {
emit ProgressChanged(episode_, PodcastDownload::Downloading, static_cast<float>(received) / total * 100);
}
}
PodcastDownloader::PodcastDownloader(Application *app, QObject *parent)
: QObject(parent),
app_(app),
backend_(app_->podcast_backend()),
network_(new NetworkAccessManager(this)),
disallowed_filename_characters_("[^a-zA-Z0-9_~ -]"),
auto_download_(false) {
QObject::connect(backend_, &PodcastBackend::EpisodesAdded, this, &PodcastDownloader::EpisodesAdded);
QObject::connect(backend_, &PodcastBackend::SubscriptionAdded, this, &PodcastDownloader::SubscriptionAdded);
QObject::connect(app_, &Application::SettingsChanged, this, &PodcastDownloader::ReloadSettings);
ReloadSettings();
}
QString PodcastDownloader::DefaultDownloadDir() const {
return QDir::homePath() + "/Podcasts";
}
void PodcastDownloader::ReloadSettings() {
QSettings s;
s.beginGroup(kSettingsGroup);
auto_download_ = s.value("auto_download", false).toBool();
download_dir_ = s.value("download_dir", DefaultDownloadDir()).toString();
}
QString PodcastDownloader::FilenameForEpisode(const QString &directory, const PodcastEpisode &episode) const {
const QString file_extension = QFileInfo(episode.url().path()).suffix();
int count = 0;
// The file name contains the publication date and episode title
QString base_filename = episode.publication_date().date().toString(Qt::ISODate) + "-" + SanitiseFilenameComponent(episode.title());
// Add numbers on to the end of the filename until we find one that doesn't exist.
forever {
QString filename;
if (count == 0) {
filename = QString("%1/%2.%3").arg(directory, base_filename, file_extension);
}
else {
filename = QString("%1/%2 (%3).%4").arg(directory, base_filename, QString::number(count), file_extension);
}
if (!QFile::exists(filename)) {
return filename;
}
++count;
}
}
void PodcastDownloader::DownloadEpisode(const PodcastEpisode &episode) {
for (Task *tas : list_tasks_) {
if (tas->episode().database_id() == episode.database_id()) {
return;
}
}
Podcast podcast = backend_->GetSubscriptionById(episode.podcast_database_id());
if (!podcast.is_valid()) {
qLog(Warning) << "The podcast that contains episode" << episode.url() << "doesn't exist any more";
return;
}
const QString directory = download_dir_ + "/" + SanitiseFilenameComponent(podcast.title());
const QString filepath = FilenameForEpisode(directory, episode);
// Open the output file
if (!QDir(directory).exists()) QDir().mkpath(directory);
QFile *file = new QFile(filepath);
if (!file->open(QIODevice::WriteOnly)) {
qLog(Warning) << "Could not open the file" << filepath << "for writing";
return;
}
Task *task = new Task(episode, file, backend_);
list_tasks_ << task;
qLog(Info) << "Downloading" << task->episode().url() << "to" << filepath;
QObject::connect(task, &Task::finished, this, &PodcastDownloader::ReplyFinished);
QObject::connect(task, &Task::ProgressChanged, this, &PodcastDownloader::ProgressChanged);
}
void PodcastDownloader::ReplyFinished(Task *task) {
list_tasks_.removeAll(task);
delete task;
}
QString PodcastDownloader::SanitiseFilenameComponent(const QString &text) const {
return QString(text).replace(disallowed_filename_characters_, " ") .simplified();
}
void PodcastDownloader::SubscriptionAdded(const Podcast &podcast) {
EpisodesAdded(podcast.episodes());
}
void PodcastDownloader::EpisodesAdded(const PodcastEpisodeList &episodes) {
if (auto_download_) {
for (const PodcastEpisode &episode : episodes) {
DownloadEpisode(episode);
}
}
}
PodcastEpisodeList PodcastDownloader::EpisodesDownloading(const PodcastEpisodeList &episodes) {
PodcastEpisodeList ret;
for (Task *tas : list_tasks_) {
for (const PodcastEpisode &episode : episodes) {
if (tas->episode().database_id() == episode.database_id()) {
ret << episode;
}
}
}
return ret;
}
void PodcastDownloader::cancelDownload(const PodcastEpisodeList &episodes) {
QList<Task*> ta;
for (Task *tas : list_tasks_) {
for (const PodcastEpisode &episode : episodes) {
if (tas->episode().database_id() == episode.database_id()) {
ta << tas;
}
}
}
for (Task *tas : ta) {
tas->finishedPublic();
list_tasks_.removeAll(tas);
}
}

View File

@@ -0,0 +1,129 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTDOWNLOADER_H
#define PODCASTDOWNLOADER_H
#include <memory>
#include <QObject>
#include <QFile>
#include <QSet>
#include <QList>
#include <QQueue>
#include <QString>
#include <QRegularExpression>
#include <QNetworkRequest>
#include "core/networkaccessmanager.h"
#include "podcast.h"
#include "podcastepisode.h"
class Application;
class PodcastBackend;
class NetworkAccessManager;
class QNetworkReply;
namespace PodcastDownload {
enum State {
NotDownloading,
Queued,
Downloading,
Finished
};
}
class Task : public QObject {
Q_OBJECT
public:
Task(const PodcastEpisode &episode, QFile *file, PodcastBackend *backend);
PodcastEpisode episode() const;
signals:
void ProgressChanged(const PodcastEpisode &episode, const PodcastDownload::State state, const int percent);
void finished(Task *task);
public slots:
void finishedPublic();
private slots:
void reading();
void downloadProgressInternal(qint64 received, qint64 total);
void finishedInternal();
private:
std::unique_ptr<QFile> file_;
PodcastEpisode episode_;
PodcastBackend *backend_;
std::unique_ptr<NetworkAccessManager> network_;
QNetworkRequest req_;
QNetworkReply *reply_;
};
class PodcastDownloader : public QObject {
Q_OBJECT
public:
explicit PodcastDownloader(Application *app, QObject *parent = nullptr);
PodcastEpisodeList EpisodesDownloading(const PodcastEpisodeList &episodes);
QString DefaultDownloadDir() const;
public slots:
// Adds the episode to the download queue
void DownloadEpisode(const PodcastEpisode &episode);
void cancelDownload(const PodcastEpisodeList &episodes);
signals:
void ProgressChanged(const PodcastEpisode &episode, const PodcastDownload::State state, const int percent);
private slots:
void ReloadSettings();
void SubscriptionAdded(const Podcast &podcast);
void EpisodesAdded(const PodcastEpisodeList &episodes);
void ReplyFinished(Task *task);
private:
QString FilenameForEpisode(const QString &directory, const PodcastEpisode &episode) const;
QString SanitiseFilenameComponent(const QString &text) const;
private:
static const char *kSettingsGroup;
Application *app_;
PodcastBackend *backend_;
NetworkAccessManager *network_;
QRegularExpression disallowed_filename_characters_;
bool auto_download_;
QString download_dir_;
QList<Task*> list_tasks_;
};
#endif // PODCASTDOWNLOADER_H

View File

@@ -0,0 +1,231 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <limits>
#include <QSharedData>
#include <QIODevice>
#include <QFile>
#include <QFileInfo>
#include <QDataStream>
#include <QVariant>
#include <QVariantMap>
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDateTime>
#include <QSqlQuery>
#include "core/logging.h"
#include "core/timeconstants.h"
#include "core/utilities.h"
#include "podcast.h"
#include "podcastepisode.h"
const QStringList PodcastEpisode::kColumns = QStringList() << "podcast_id"
<< "title"
<< "description"
<< "author"
<< "publication_date"
<< "duration_secs"
<< "url"
<< "listened"
<< "listened_date"
<< "downloaded"
<< "local_url"
<< "extra";
const QString PodcastEpisode::kColumnSpec = PodcastEpisode::kColumns.join(", ");
const QString PodcastEpisode::kJoinSpec = Utilities::Prepend("e.", PodcastEpisode::kColumns).join(", ");
const QString PodcastEpisode::kBindSpec = Utilities::Prepend(":", PodcastEpisode::kColumns).join(", ");
const QString PodcastEpisode::kUpdateSpec = Utilities::Updateify(PodcastEpisode::kColumns).join(", ");
struct PodcastEpisode::Private : public QSharedData {
Private();
int database_id_;
int podcast_database_id_;
QString title_;
QString description_;
QString author_;
QDateTime publication_date_;
int duration_secs_;
QUrl url_;
bool listened_;
QDateTime listened_date_;
bool downloaded_;
QUrl local_url_;
QVariantMap extra_;
};
PodcastEpisode::Private::Private()
: database_id_(-1),
podcast_database_id_(-1),
duration_secs_(-1),
listened_(false),
downloaded_(false) {}
PodcastEpisode::PodcastEpisode() : d(new Private) {}
PodcastEpisode::PodcastEpisode(const PodcastEpisode &other) : d(other.d) {}
PodcastEpisode::~PodcastEpisode() {}
PodcastEpisode &PodcastEpisode::operator=(const PodcastEpisode &other) {
d = other.d;
return *this;
}
int PodcastEpisode::database_id() const { return d->database_id_; }
int PodcastEpisode::podcast_database_id() const {
return d->podcast_database_id_;
}
const QString &PodcastEpisode::title() const { return d->title_; }
const QString &PodcastEpisode::description() const { return d->description_; }
const QString &PodcastEpisode::author() const { return d->author_; }
const QDateTime &PodcastEpisode::publication_date() const { return d->publication_date_; }
int PodcastEpisode::duration_secs() const { return d->duration_secs_; }
const QUrl &PodcastEpisode::url() const { return d->url_; }
bool PodcastEpisode::listened() const { return d->listened_; }
const QDateTime &PodcastEpisode::listened_date() const { return d->listened_date_; }
bool PodcastEpisode::downloaded() const { return d->downloaded_; }
const QUrl &PodcastEpisode::local_url() const { return d->local_url_; }
const QVariantMap &PodcastEpisode::extra() const { return d->extra_; }
QVariant PodcastEpisode::extra(const QString &key) const { return d->extra_[key]; }
void PodcastEpisode::set_database_id(const int v) { d->database_id_ = v; }
void PodcastEpisode::set_podcast_database_id(const int v) { d->podcast_database_id_ = v; }
void PodcastEpisode::set_title(const QString &v) { d->title_ = v; }
void PodcastEpisode::set_description(const QString &v) { d->description_ = v; }
void PodcastEpisode::set_author(const QString &v) { d->author_ = v; }
void PodcastEpisode::set_publication_date(const QDateTime &v) { d->publication_date_ = v; }
void PodcastEpisode::set_duration_secs(int v) { d->duration_secs_ = v; }
void PodcastEpisode::set_url(const QUrl &v) { d->url_ = v; }
void PodcastEpisode::set_listened(const bool v) { d->listened_ = v; }
void PodcastEpisode::set_listened_date(const QDateTime &v) { d->listened_date_ = v; }
void PodcastEpisode::set_downloaded(const bool v) { d->downloaded_ = v; }
void PodcastEpisode::set_local_url(const QUrl &v) { d->local_url_ = v; }
void PodcastEpisode::set_extra(const QVariantMap &v) { d->extra_ = v; }
void PodcastEpisode::set_extra(const QString &key, const QVariant &value) { d->extra_[key] = value; }
void PodcastEpisode::InitFromQuery(const QSqlQuery &query) {
d->database_id_ = query.value(0).toInt();
d->podcast_database_id_ = query.value(1).toInt();
d->title_ = query.value(2).toString();
d->description_ = query.value(3).toString();
d->author_ = query.value(4).toString();
d->publication_date_ = QDateTime::fromSecsSinceEpoch(query.value(5).toUInt());
d->duration_secs_ = query.value(6).toInt();
d->url_ = QUrl::fromEncoded(query.value(7).toByteArray());
d->listened_ = query.value(8).toBool();
// After setting QDateTime to invalid state, it's saved into database as time_t,
// when this number std::numeric_limits<unsigned int>::max() (4294967295) is read back from database, it creates a valid QDateTime.
// So to make it behave consistently, this change is needed.
if (query.value(9).toUInt() == std::numeric_limits<unsigned int>::max()) {
d->listened_date_ = QDateTime();
}
else {
d->listened_date_ = QDateTime::fromSecsSinceEpoch(query.value(9).toUInt());
}
d->downloaded_ = query.value(10).toBool();
d->local_url_ = QUrl::fromEncoded(query.value(11).toByteArray());
QDataStream extra_stream(query.value(12).toByteArray());
extra_stream >> d->extra_;
}
void PodcastEpisode::BindToQuery(QSqlQuery* query) const {
query->bindValue(":podcast_id", d->podcast_database_id_);
query->bindValue(":title", d->title_);
query->bindValue(":description", d->description_);
query->bindValue(":author", d->author_);
query->bindValue(":publication_date", d->publication_date_.toSecsSinceEpoch());
query->bindValue(":duration_secs", d->duration_secs_);
query->bindValue(":url", d->url_.toEncoded());
query->bindValue(":listened", d->listened_);
query->bindValue(":listened_date", d->listened_date_.toSecsSinceEpoch());
query->bindValue(":downloaded", d->downloaded_);
query->bindValue(":local_url", d->local_url_.toEncoded());
QByteArray extra;
QDataStream extra_stream(&extra, QIODevice::WriteOnly);
extra_stream << d->extra_;
query->bindValue(":extra", extra);
}
Song PodcastEpisode::ToSong(const Podcast &podcast) const {
Song ret;
ret.set_valid(true);
ret.set_title(title().simplified());
ret.set_artist(author().simplified());
ret.set_length_nanosec(kNsecPerSec * duration_secs());
ret.set_year(publication_date().date().year());
ret.set_comment(description());
ret.set_id(database_id());
ret.set_ctime(publication_date().toSecsSinceEpoch());
ret.set_genre(QString("Podcast"));
//ret.set_genre_id3(186);
if (listened() && listened_date().isValid()) {
ret.set_mtime(listened_date().toSecsSinceEpoch());
}
else {
ret.set_mtime(publication_date().toSecsSinceEpoch());
}
if (ret.length_nanosec() < 0) {
ret.set_length_nanosec(-1);
}
if (downloaded() && QFile::exists(local_url().toLocalFile())) {
ret.set_url(local_url());
}
else {
ret.set_url(url());
}
ret.set_basefilename(QFileInfo(ret.url().path()).fileName());
// Use information from the podcast if it's set
if (podcast.is_valid()) {
ret.set_album(podcast.title().simplified());
ret.set_art_automatic(podcast.ImageUrlLarge());
if (author().isEmpty()) ret.set_artist(podcast.title().simplified());
}
return ret;
}

View File

@@ -0,0 +1,100 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTEPISODE_H
#define PODCASTEPISODE_H
#include <QSharedDataPointer>
#include <QVariant>
#include <QVariantMap>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <QDateTime>
#include <QSqlQuery>
#include "core/song.h"
class Podcast;
class PodcastEpisode {
public:
PodcastEpisode();
PodcastEpisode(const PodcastEpisode &other);
~PodcastEpisode();
static const QStringList kColumns;
static const QString kColumnSpec;
static const QString kJoinSpec;
static const QString kBindSpec;
static const QString kUpdateSpec;
void InitFromQuery(const QSqlQuery &query);
void BindToQuery(QSqlQuery *query) const;
Song ToSong(const Podcast &podcast) const;
bool is_valid() const { return database_id() != -1; }
int database_id() const;
int podcast_database_id() const;
const QString &title() const;
const QString &description() const;
const QString &author() const;
const QDateTime &publication_date() const;
int duration_secs() const;
const QUrl &url() const;
bool listened() const;
const QDateTime &listened_date() const;
bool downloaded() const;
const QUrl &local_url() const;
const QVariantMap &extra() const;
QVariant extra(const QString &key) const;
void set_database_id(const int v);
void set_podcast_database_id(int v);
void set_title(const QString &v);
void set_description(const QString &v);
void set_author(const QString &v);
void set_publication_date(const QDateTime &v);
void set_duration_secs(int v);
void set_url(const QUrl &v);
void set_listened(const bool v);
void set_listened_date(const QDateTime &v);
void set_downloaded(const bool v);
void set_local_url(const QUrl &v);
void set_extra(const QVariantMap &v);
void set_extra(const QString &key, const QVariant &value);
PodcastEpisode &operator=(const PodcastEpisode &other);
private:
struct Private;
QSharedDataPointer<Private> d;
};
Q_DECLARE_METATYPE(PodcastEpisode)
typedef QList<PodcastEpisode> PodcastEpisodeList;
Q_DECLARE_METATYPE(QList<PodcastEpisode>)
#endif // PODCASTEPISODE_H

View File

@@ -0,0 +1,59 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QDialog>
#include "core/application.h"
#include "podcastepisode.h"
#include "podcastinfodialog.h"
#include "ui_podcastinfodialog.h"
PodcastInfoDialog::PodcastInfoDialog(Application *app, QWidget *parent)
: QDialog(parent), app_(app), ui_(new Ui_PodcastInfoDialog) {
ui_->setupUi(this);
ui_->podcast_details->SetApplication(app);
ui_->episode_details->SetApplication(app);
}
PodcastInfoDialog::~PodcastInfoDialog() { delete ui_; }
void PodcastInfoDialog::ShowPodcast(const Podcast &podcast) {
ui_->episode_info_scroll_area->hide();
ui_->podcast_url->setText(podcast.url().toString());
ui_->podcast_url->setReadOnly(true);
ui_->podcast_details->SetPodcast(podcast);
show();
}
void PodcastInfoDialog::ShowEpisode(const PodcastEpisode &episode, const Podcast &podcast) {
ui_->episode_info_scroll_area->show();
ui_->podcast_url->setText(episode.url().toString());
ui_->podcast_url->setReadOnly(true);
ui_->podcast_details->SetPodcast(podcast);
ui_->episode_details->SetEpisode(episode);
show();
}

View File

@@ -0,0 +1,48 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
Copyright 2018, Jim Broadus <jbroadus@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTINFODIALOG_H
#define PODCASTINFODIALOG_H
#include <QDialog>
class Application;
class Podcast;
class PodcastEpisode;
class Ui_PodcastInfoDialog;
class PodcastInfoDialog : public QDialog {
Q_OBJECT
public:
explicit PodcastInfoDialog(Application *app, QWidget *parent = nullptr);
~PodcastInfoDialog();
void ShowPodcast(const Podcast &podcast);
void ShowEpisode(const PodcastEpisode &episode, const Podcast &podcast);
private:
Application *app_;
Ui_PodcastInfoDialog *ui_;
};
#endif // PODCASTINFODIALOG_H

View File

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

View File

@@ -0,0 +1,134 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QWidget>
#include <QImage>
#include <QPixmap>
#include <QColor>
#include <QPalette>
#include <QLabel>
#include "core/application.h"
#include "covermanager/albumcoverloader.h"
#include "covermanager/albumcoverloaderoptions.h"
#include "covermanager/albumcoverloaderresult.h"
#include "podcastinfowidget.h"
#include "ui_podcastinfowidget.h"
PodcastInfoWidget::PodcastInfoWidget(QWidget *parent)
: QWidget(parent),
ui_(new Ui_PodcastInfoWidget),
app_(nullptr),
image_id_(0) {
ui_->setupUi(this);
cover_options_.desired_height_ = 180;
ui_->image->setFixedSize(cover_options_.desired_height_, cover_options_.desired_height_);
// Set the colour of all the labels
const bool light = palette().color(QPalette::Base).value() > 128;
const QColor color = palette().color(QPalette::Dark);
QPalette label_palette(palette());
label_palette.setColor(QPalette::WindowText, light ? color.darker(150) : color.lighter(125));
for (QLabel* label : findChildren<QLabel*>()) {
if (label->property("field_label").toBool()) {
label->setPalette(label_palette);
}
}
}
PodcastInfoWidget::~PodcastInfoWidget() { delete ui_; }
void PodcastInfoWidget::SetApplication(Application *app) {
app_ = app;
connect(app_->album_cover_loader(), &AlbumCoverLoader::AlbumCoverLoaded, this, &PodcastInfoWidget::AlbumCoverLoaded);
}
namespace {
template<typename T>
void SetText(const QString& value, T* label, QLabel* buddy_label = nullptr) {
const bool visible = !value.isEmpty();
label->setVisible(visible);
if (buddy_label) {
buddy_label->setVisible(visible);
}
if (visible) {
label->setText(value);
}
}
} // namespace
void PodcastInfoWidget::SetPodcast(const Podcast &podcast) {
if (image_id_) {
app_->album_cover_loader()->CancelTask(image_id_);
image_id_ = 0;
}
podcast_ = podcast;
if (podcast.ImageUrlLarge().isValid()) {
// Start loading an image for this item.
image_id_ = app_->album_cover_loader()->LoadImageAsync(cover_options_, podcast.ImageUrlLarge(), QUrl());
}
ui_->image->hide();
SetText(podcast.title(), ui_->title);
SetText(podcast.description(), ui_->description);
SetText(podcast.copyright(), ui_->copyright, ui_->copyright_label);
SetText(podcast.author(), ui_->author, ui_->author_label);
SetText(podcast.owner_name(), ui_->owner, ui_->owner_label);
SetText(podcast.link().toString(), ui_->website, ui_->website_label);
SetText(podcast.extra("gpodder:subscribers").toString(), ui_->subscribers, ui_->subscribers_label);
if (!image_id_) {
emit LoadingFinished();
}
}
void PodcastInfoWidget::AlbumCoverLoaded(const quint64 id, const AlbumCoverLoaderResult &result) {
if (id != image_id_) {
return;
}
image_id_ = 0;
if (result.success && !result.image_scaled.isNull()) {
ui_->image->setPixmap(QPixmap::fromImage(result.image_scaled));
ui_->image->show();
}
emit LoadingFinished();
}

View File

@@ -0,0 +1,65 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTINFOWIDGET_H
#define PODCASTINFOWIDGET_H
#include <QWidget>
#include "podcast.h"
#include "covermanager/albumcoverloaderoptions.h"
#include "covermanager/albumcoverloaderresult.h"
class Application;
class Ui_PodcastInfoWidget;
class QLabel;
class PodcastInfoWidget : public QWidget {
Q_OBJECT
public:
explicit PodcastInfoWidget(QWidget *parent = nullptr);
~PodcastInfoWidget();
void SetApplication(Application *app);
void SetPodcast(const Podcast& podcast);
signals:
void LoadingFinished();
private slots:
void AlbumCoverLoaded(const quint64 id, const AlbumCoverLoaderResult &result);
private:
Ui_PodcastInfoWidget *ui_;
AlbumCoverLoaderOptions cover_options_;
Application *app_;
Podcast podcast_;
quint64 image_id_;
};
#endif // PODCASTINFOWIDGET_H

View File

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

View File

@@ -0,0 +1,376 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QByteArray>
#include <QString>
#include <QUrl>
#include <QDateTime>
#include <QXmlStreamReader>
#include <QRegularExpression>
#include "core/logging.h"
#include "core/utilities.h"
#include "podcastparser.h"
#include "opmlcontainer.h"
// Namespace constants must be lower case.
const char *PodcastParser::kAtomNamespace = "http://www.w3.org/2005/atom";
const char *PodcastParser::kItunesNamespace = "http://www.itunes.com/dtds/podcast-1.0.dtd";
PodcastParser::PodcastParser() {
supported_mime_types_ << "application/rss+xml"
<< "application/xml"
<< "text/x-opml"
<< "text/xml";
}
bool PodcastParser::SupportsContentType(const QString &content_type) const {
if (content_type.isEmpty()) {
// Why not have a go.
return true;
}
for (const QString &mime_type : supported_mime_types()) {
if (content_type.contains(mime_type)) {
return true;
}
}
return false;
}
bool PodcastParser::TryMagic(const QByteArray &data) const {
QString str(QString::fromUtf8(data));
return str.contains(QRegularExpression("<rss\\b")) || str.contains(QRegularExpression("<opml\\b"));
}
QVariant PodcastParser::Load(QIODevice *device, const QUrl &url) const {
QXmlStreamReader reader(device);
while (!reader.atEnd()) {
switch (reader.readNext()) {
case QXmlStreamReader::StartElement: {
const QString name = reader.name().toString();
if (name == "rss") {
Podcast podcast;
if (!ParseRss(&reader, &podcast)) {
return QVariant();
}
else {
podcast.set_url(url);
return QVariant::fromValue(podcast);
}
}
else if (name == "opml") {
OpmlContainer container;
if (!ParseOpml(&reader, &container)) {
return QVariant();
}
else {
container.url = url;
return QVariant::fromValue(container);
}
}
return QVariant();
}
default:
break;
}
}
return QVariant();
}
bool PodcastParser::ParseRss(QXmlStreamReader *reader, Podcast *ret) const {
if (!Utilities::ParseUntilElement(reader, "channel")) {
return false;
}
ParseChannel(reader, ret);
return true;
}
void PodcastParser::ParseChannel(QXmlStreamReader *reader, Podcast *ret) const {
while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext();
switch (type) {
case QXmlStreamReader::StartElement: {
const QString name = reader->name().toString();
const QString lower_namespace = reader->namespaceUri().toString().toLower();
if (name == "title") {
ret->set_title(reader->readElementText());
}
else if (name == "link" && lower_namespace.isEmpty()) {
ret->set_link(QUrl::fromEncoded(reader->readElementText().toLatin1()));
}
else if (name == "description") {
ret->set_description(reader->readElementText());
}
else if (name == "owner" && lower_namespace == kItunesNamespace) {
ParseItunesOwner(reader, ret);
}
else if (name == "image") {
ParseImage(reader, ret);
}
else if (name == "copyright") {
ret->set_copyright(reader->readElementText());
}
else if (name == "link" && lower_namespace == kAtomNamespace && ret->url().isEmpty() && reader->attributes().value("rel").toString() == "self") {
ret->set_url(QUrl::fromEncoded(reader->readElementText().toLatin1()));
}
else if (name == "item") {
ParseItem(reader, ret);
}
else {
Utilities::ConsumeCurrentElement(reader);
}
break;
}
case QXmlStreamReader::EndElement:
return;
default:
break;
}
}
}
void PodcastParser::ParseImage(QXmlStreamReader *reader, Podcast *ret) const {
while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext();
switch (type) {
case QXmlStreamReader::StartElement: {
const QString name = reader->name().toString();
if (name == "url") {
ret->set_image_url_large(
QUrl::fromEncoded(reader->readElementText().toLatin1()));
}
else {
Utilities::ConsumeCurrentElement(reader);
}
break;
}
case QXmlStreamReader::EndElement:
return;
default:
break;
}
}
}
void PodcastParser::ParseItunesOwner(QXmlStreamReader *reader, Podcast *ret) const {
while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext();
switch (type) {
case QXmlStreamReader::StartElement: {
const QString name = reader->name().toString();
if (name == "name") {
ret->set_owner_name(reader->readElementText());
}
else if (name == "email") {
ret->set_owner_email(reader->readElementText());
}
else {
Utilities::ConsumeCurrentElement(reader);
}
break;
}
case QXmlStreamReader::EndElement:
return;
default:
break;
}
}
}
void PodcastParser::ParseItem(QXmlStreamReader *reader, Podcast *ret) const {
PodcastEpisode episode;
while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext();
switch (type) {
case QXmlStreamReader::StartElement: {
const QString name = reader->name().toString();
const QString lower_namespace = reader->namespaceUri().toString().toLower();
if (name == "title") {
episode.set_title(reader->readElementText());
}
else if (name == "description") {
episode.set_description(reader->readElementText());
}
else if (name == "pubDate") {
QString date = reader->readElementText();
episode.set_publication_date(Utilities::ParseRFC822DateTime(date));
if (!episode.publication_date().isValid()) {
qLog(Error) << "Unable to parse date:" << date;
}
}
else if (name == "duration" && lower_namespace == kItunesNamespace) {
// http://www.apple.com/itunes/podcasts/specs.html
QStringList parts = reader->readElementText().split(':');
if (parts.count() == 2) {
episode.set_duration_secs(parts[0].toInt() * 60 + parts[1].toInt());
}
else if (parts.count() >= 3) {
episode.set_duration_secs(parts[0].toInt() * 60 * 60 + parts[1].toInt() * 60 + parts[2].toInt());
}
}
else if (name == "enclosure") {
const QString type2 = reader->attributes().value("type").toString();
const QUrl url = QUrl::fromEncoded(reader->attributes().value("url").toString().toLatin1());
if (type2.startsWith("audio/") || type2.startsWith("x-audio/")) {
episode.set_url(url);
}
// If the URL doesn't have a type, see if it's one of the obvious types
else if (type2.isEmpty() && (url.path().endsWith(".mp3", Qt::CaseInsensitive) || url.path().endsWith(".m4a", Qt::CaseInsensitive) || url.path().endsWith(".wav", Qt::CaseInsensitive))) {
episode.set_url(url);
}
Utilities::ConsumeCurrentElement(reader);
}
else if (name == "author" && lower_namespace == kItunesNamespace) {
episode.set_author(reader->readElementText());
}
else {
Utilities::ConsumeCurrentElement(reader);
}
break;
}
case QXmlStreamReader::EndElement:
if (!episode.publication_date().isValid()) {
episode.set_publication_date(QDateTime::currentDateTime());
}
if (!episode.url().isEmpty()) {
ret->add_episode(episode);
}
return;
default:
break;
}
}
}
bool PodcastParser::ParseOpml(QXmlStreamReader *reader, OpmlContainer *ret) const {
if (!Utilities::ParseUntilElement(reader, "body")) {
return false;
}
ParseOutline(reader, ret);
// OPML files sometimes consist of a single top level container.
OpmlContainer *top = ret;
while (top->feeds.count() == 0 && top->containers.count() == 1) {
top = &top->containers[0];
}
if (top != ret) {
// Copy the sub-container to a temporary location first.
OpmlContainer tmp = *top;
*ret = tmp;
}
return true;
}
void PodcastParser::ParseOutline(QXmlStreamReader *reader, OpmlContainer *ret) const {
while (!reader->atEnd()) {
QXmlStreamReader::TokenType type = reader->readNext();
switch (type) {
case QXmlStreamReader::StartElement: {
const QString name = reader->name().toString();
if (name != "outline") {
Utilities::ConsumeCurrentElement(reader);
continue;
}
QXmlStreamAttributes attributes = reader->attributes();
if (attributes.value("type").toString() == "rss") {
// Parse the feed and add it to this container
Podcast podcast;
podcast.set_description(attributes.value("description").toString());
QString title = attributes.value("title").toString();
if (title.isEmpty()) {
title = attributes.value("text").toString();
}
podcast.set_title(title);
podcast.set_image_url_large(QUrl::fromEncoded(attributes.value("imageHref").toString().toLatin1()));
podcast.set_url(QUrl::fromEncoded(attributes.value("xmlUrl").toString().toLatin1()));
ret->feeds.append(podcast);
// Consume any children and the EndElement.
Utilities::ConsumeCurrentElement(reader);
}
else {
// Create a new child container
OpmlContainer child;
// Take the name from the fullname attribute first if it exists.
child.name = attributes.value("fullname").toString();
if (child.name.isEmpty()) {
child.name = attributes.value("text").toString();
}
// Parse its contents and add it to this container
ParseOutline(reader, &child);
ret->containers.append(child);
}
break;
}
case QXmlStreamReader::EndElement:
return;
default:
break;
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTPARSER_H
#define PODCASTPARSER_H
#include <QByteArray>
#include <QString>
#include <QStringList>
#include <QUrl>
#include "podcast.h"
class QIODevice;
class QXmlStreamReader;
class OpmlContainer;
// Reads XML data from a QIODevice.
// Returns either a Podcast or an OpmlContainer depending on what was inside the XML document.
class PodcastParser {
public:
PodcastParser();
static const char *kAtomNamespace;
static const char *kItunesNamespace;
const QStringList &supported_mime_types() const { return supported_mime_types_; }
bool SupportsContentType(const QString &content_type) const;
// You should check the type of the returned QVariant to see whether it contains a Podcast or an OpmlContainer.
// If the QVariant isNull then an error occurred parsing the XML.
QVariant Load(QIODevice *device, const QUrl &url) const;
// Really quick test to see if some data might be supported. Load() might still return a null QVariant.
bool TryMagic(const QByteArray &data) const;
private:
bool ParseRss(QXmlStreamReader *reader, Podcast *ret) const;
void ParseChannel(QXmlStreamReader *reader, Podcast *ret) const;
void ParseImage(QXmlStreamReader *reader, Podcast *ret) const;
void ParseItunesOwner(QXmlStreamReader *reader, Podcast *ret) const;
void ParseItem(QXmlStreamReader *reader, Podcast *ret) const;
bool ParseOpml(QXmlStreamReader *reader, OpmlContainer *ret) const;
void ParseOutline(QXmlStreamReader *reader, OpmlContainer *ret) const;
private:
QStringList supported_mime_types_;
};
#endif // PODCASTPARSER_H

View File

@@ -0,0 +1,919 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012-2013, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2013-2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2014, Simeon Bird <sbird@andrew.cmu.edu>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <memory>
#include "podcastservice.h"
#include <QObject>
#include <QtConcurrentRun>
#include <QSet>
#include <QMap>
#include <QVariant>
#include <QString>
#include <QIcon>
#include <QDateTime>
#include <QFont>
#include <QStandardItemModel>
#include <QStandardItem>
#include <QSortFilterProxyModel>
#include <QMenu>
#include <QAction>
#include "core/application.h"
#include "core/logging.h"
#include "core/mergedproxymodel.h"
#include "core/iconloader.h"
#include "core/standarditemiconloader.h"
//#include "podcastsmodel.h"
#include "podcastservicemodel.h"
#include "collection/collectionview.h"
#include "opmlcontainer.h"
#include "podcastbackend.h"
#include "podcastdeleter.h"
#include "podcastdownloader.h"
#include "podcastinfodialog.h"
#include "podcastupdater.h"
#include "addpodcastdialog.h"
#include "organize/organizedialog.h"
#include "organize/organizeerrordialog.h"
#include "playlist/playlistmanager.h"
#include "device/devicemanager.h"
#include "device/devicestatefiltermodel.h"
#include "device/deviceview.h"
const char* PodcastService::kServiceName = "Podcasts";
const char *PodcastService::kSettingsGroup = "Podcasts";
class PodcastSortProxyModel : public QSortFilterProxyModel {
public:
explicit PodcastSortProxyModel(QObject *parent = nullptr);
protected:
bool lessThan(const QModelIndex &left, const QModelIndex &right) const;
};
PodcastService::PodcastService(Application *app, QObject *parent)
: InternetService(Song::Source_Unknown, kServiceName, QString(), QString(), SettingsDialog::Page_Appearance, app, parent),
use_pretty_covers_(true),
hide_listened_(false),
show_episodes_(0),
icon_loader_(new StandardItemIconLoader(app->album_cover_loader(), this)),
backend_(app->podcast_backend()),
model_(new PodcastServiceModel(this)),
proxy_(new PodcastSortProxyModel(this)),
root_(nullptr),
organize_dialog_(new OrganizeDialog(app_->task_manager())) {
icon_loader_->SetModel(model_);
proxy_->setSourceModel(model_);
proxy_->setDynamicSortFilter(true);
proxy_->sort(0);
QObject::connect(backend_, &PodcastBackend::SubscriptionAdded, this, &PodcastService::SubscriptionAdded);
QObject::connect(backend_, &PodcastBackend::SubscriptionRemoved, this, &PodcastService::SubscriptionRemoved);
QObject::connect(backend_, &PodcastBackend::EpisodesAdded, this, &PodcastService::EpisodesAdded);
QObject::connect(backend_, &PodcastBackend::EpisodesUpdated, this, &PodcastService::EpisodesUpdated);
QObject::connect(app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, this, &PodcastService::CurrentSongChanged);
QObject::connect(organize_dialog_.get(), &OrganizeDialog::FileCopied, this, &PodcastService::FileCopied);
}
PodcastService::~PodcastService() {}
PodcastSortProxyModel::PodcastSortProxyModel(QObject *parent) : QSortFilterProxyModel(parent) {}
bool PodcastSortProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const {
Q_UNUSED(left)
Q_UNUSED(right)
#if 0
const int left_type = left.data(InternetModel::Role_Type).toInt();
const int right_type = right.data(InternetModel::Role_Type).toInt();
// The special Add Podcast item comes first
if (left_type == PodcastService::Type_AddPodcast)
return true;
else if (right_type == PodcastService::Type_AddPodcast)
return false;
// Otherwise we only compare identical typed items.
if (left_type != right_type)
return QSortFilterProxyModel::lessThan(left, right);
switch (left_type) {
case PodcastService::Type_Podcast:
return left.data().toString().localeAwareCompare(right.data().toString()) < 0;
case PodcastService::Type_Episode: {
const PodcastEpisode left_episode = left.data(PodcastService::Role_Episode).value<PodcastEpisode>();
const PodcastEpisode right_episode = right.data(PodcastService::Role_Episode).value<PodcastEpisode>();
return left_episode.publication_date() > right_episode.publication_date();
}
default:
return QSortFilterProxyModel::lessThan(left, right);
}
#endif
return false;
}
QStandardItem *PodcastService::CreateRootItem() {
#if 0
root_ = new QStandardItem(IconLoader::Load("podcast"), tr("Podcasts"));
root_->setData(true, InternetModel::Role_CanLazyLoad);
return root_;
#endif
return nullptr;
}
void PodcastService::CopyToDevice() {
if (selected_episodes_.isEmpty() && explicitly_selected_podcasts_.isEmpty()) {
CopyToDevice(backend_->GetNewDownloadedEpisodes());
}
else {
CopyToDevice(selected_episodes_, explicitly_selected_podcasts_);
}
}
void PodcastService::CopyToDevice(const PodcastEpisodeList &episodes_list) {
SongList songs;
Podcast podcast;
for (const PodcastEpisode &episode : episodes_list) {
podcast = backend_->GetSubscriptionById(episode.podcast_database_id());
songs.append(episode.ToSong(podcast));
}
if (songs.isEmpty()) return;
organize_dialog_->SetDestinationModel(app_->device_manager()->connected_devices_model(), true);
organize_dialog_->SetCopy(true);
if (organize_dialog_->SetSongs(songs)) organize_dialog_->show();
}
void PodcastService::CopyToDevice(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes) {
PodcastEpisodeList episodes;
for (const QModelIndex &idx : episode_indexes) {
PodcastEpisode episode_tmp = idx.data(Role_Episode).value<PodcastEpisode>();
if (episode_tmp.downloaded()) episodes << episode_tmp;
}
for (const QModelIndex &idx : podcast_indexes) {
for (int i = 0; i < idx.model()->rowCount(idx); ++i) {
const QModelIndex &idx2 = idx.model()->index(i, 0, idx);
PodcastEpisode episode_tmp = idx2.data(Role_Episode).value<PodcastEpisode>();
if (episode_tmp.downloaded() && !episode_tmp.listened())
episodes << episode_tmp;
}
}
SongList songs;
for (const PodcastEpisode &episode : episodes) {
Podcast podcast = backend_->GetSubscriptionById(episode.podcast_database_id());
songs.append(episode.ToSong(podcast));
}
organize_dialog_->SetDestinationModel(app_->device_manager()->connected_devices_model(), true);
organize_dialog_->SetCopy(true);
if (organize_dialog_->SetSongs(songs)) organize_dialog_->show();
}
void PodcastService::CancelDownload() {
CancelDownload(selected_episodes_, explicitly_selected_podcasts_);
}
void PodcastService::CancelDownload(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes) {
PodcastEpisodeList episodes;
for (const QModelIndex &idx : episode_indexes) {
if (!idx.isValid()) continue;
PodcastEpisode episode_tmp = idx.data(Role_Episode).value<PodcastEpisode>();
episodes << episode_tmp;
}
for (const QModelIndex &idx : podcast_indexes) {
if (!idx.isValid()) continue;
for (int i = 0; i < idx.model()->rowCount(idx); ++i) {
const QModelIndex &idx2 = idx.model()->index(i, 0, idx);
if (!idx2.isValid()) continue;
PodcastEpisode episode_tmp = idx2.data(Role_Episode).value<PodcastEpisode>();
episodes << episode_tmp;
}
}
episodes = app_->podcast_downloader()->EpisodesDownloading(episodes);
app_->podcast_downloader()->cancelDownload(episodes);
}
void PodcastService::LazyPopulate(QStandardItem *parent) {
Q_UNUSED(parent)
#if 0
switch (parent->data(InternetModel::Role_Type).toInt()) {
case InternetModel::Type_Service:
PopulatePodcastList(model_->invisibleRootItem());
model()->merged_model()->AddSubModel(parent->index(), proxy_);
break;
}
#endif
}
void PodcastService::PopulatePodcastList(QStandardItem *parent) {
// Do this here since the downloader won't be created yet in the ctor.
QObject::connect(app_->podcast_downloader(), &PodcastDownloader::ProgressChanged, this, &PodcastService::DownloadProgressChanged);
if (default_icon_.isNull()) {
default_icon_ = IconLoader::Load("podcast");
}
PodcastList podcasts = backend_->GetAllSubscriptions();
for (const Podcast &podcast : podcasts) {
parent->appendRow(CreatePodcastItem(podcast));
}
}
void PodcastService::ClearPodcastList(QStandardItem *parent) {
parent->removeRows(0, parent->rowCount());
}
void PodcastService::UpdatePodcastText(QStandardItem *item, const int unlistened_count) const {
const Podcast podcast = item->data(Role_Podcast).value<Podcast>();
QString title = podcast.title().simplified();
QFont font;
if (unlistened_count > 0) {
// Add the number of new episodes after the title.
title.append(QString(" (%1)").arg(unlistened_count));
// Set a bold font
font.setBold(true);
}
item->setFont(font);
item->setText(title);
}
void PodcastService::UpdateEpisodeText(QStandardItem *item, const PodcastDownload::State state, const int percent) {
const PodcastEpisode episode = item->data(Role_Episode).value<PodcastEpisode>();
QString title = episode.title().simplified();
QString tooltip;
QFont font;
QIcon icon;
// Unlistened episodes are bold
if (!episode.listened()) {
font.setBold(true);
}
// Downloaded episodes get an icon
if (episode.downloaded()) {
if (downloaded_icon_.isNull()) {
downloaded_icon_ = IconLoader::Load("document-save");
}
icon = downloaded_icon_;
}
// Queued or downloading episodes get icons, tooltips, and maybe a title.
switch (state) {
case PodcastDownload::Queued:
if (queued_icon_.isNull()) {
queued_icon_ = IconLoader::Load("user-away");
}
icon = queued_icon_;
tooltip = tr("Download queued");
break;
case PodcastDownload::Downloading:
if (downloading_icon_.isNull()) {
downloading_icon_ = IconLoader::Load("go-down");
}
icon = downloading_icon_;
tooltip = tr("Downloading (%1%)...").arg(percent);
title = QString("[ %1% ] %2").arg(QString::number(percent), episode.title());
break;
case PodcastDownload::Finished:
case PodcastDownload::NotDownloading:
break;
}
item->setFont(font);
item->setText(title);
item->setIcon(icon);
}
void PodcastService::UpdatePodcastText(QStandardItem *item, const PodcastDownload::State state, const int percent) {
const Podcast podcast = item->data(Role_Podcast).value<Podcast>();
QString tooltip;
QIcon icon;
// Queued or downloading podcasts get icons, tooltips, and maybe a title.
switch (state) {
case PodcastDownload::Queued:
if (queued_icon_.isNull()) {
queued_icon_ = IconLoader::Load("user-away");
}
icon = queued_icon_;
item->setIcon(icon);
tooltip = tr("Download queued");
break;
case PodcastDownload::Downloading:
if (downloading_icon_.isNull()) {
downloading_icon_ = IconLoader::Load("go-down");
}
icon = downloading_icon_;
item->setIcon(icon);
tooltip = tr("Downloading (%1%)...").arg(percent);
break;
case PodcastDownload::Finished:
case PodcastDownload::NotDownloading:
if (podcast.ImageUrlSmall().isValid()) {
icon_loader_->LoadIcon(podcast.ImageUrlSmall(), QUrl(), item);
}
else {
item->setIcon(default_icon_);
}
break;
}
}
QStandardItem *PodcastService::CreatePodcastItem(const Podcast &podcast) {
QStandardItem *item = new QStandardItem;
// Add the episodes in this podcast and gather aggregate stats.
int unlistened_count = 0;
qint64 number = 0;
for (const PodcastEpisode &episode :
backend_->GetEpisodes(podcast.database_id())) {
if (!episode.listened()) {
unlistened_count++;
}
if (episode.listened() && hide_listened_) {
continue;
}
else {
item->appendRow(CreatePodcastEpisodeItem(episode));
++number;
}
if ((number >= show_episodes_) && (show_episodes_ != 0)) {
break;
}
}
item->setIcon(default_icon_);
//item->setData(Type_Podcast, InternetModel::Role_Type);
item->setData(QVariant::fromValue(podcast), Role_Podcast);
item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable);
UpdatePodcastText(item, unlistened_count);
// Load the podcast's image if it has one
if (podcast.ImageUrlSmall().isValid()) {
icon_loader_->LoadIcon(podcast.ImageUrlSmall(), QUrl(), item);
}
podcasts_by_database_id_[podcast.database_id()] = item;
return item;
}
QStandardItem *PodcastService::CreatePodcastEpisodeItem(const PodcastEpisode &episode) {
QStandardItem *item = new QStandardItem;
item->setText(episode.title().simplified());
//item->setData(Type_Episode, InternetModel::Role_Type);
item->setData(QVariant::fromValue(episode), Role_Episode);
//item->setData(InternetModel::PlayBehaviour_UseSongLoader, InternetModel::Role_PlayBehaviour);
item->setFlags(Qt::ItemIsEnabled | Qt::ItemIsDragEnabled | Qt::ItemIsSelectable);
UpdateEpisodeText(item);
episodes_by_database_id_[episode.database_id()] = item;
return item;
}
void PodcastService::ShowContextMenu(const QPoint &global_pos) {
if (!context_menu_) {
context_menu_ = new QMenu;
context_menu_->addAction(IconLoader::Load("list-add"), tr("Add podcast..."), this, &PodcastService::AddPodcast);
context_menu_->addAction(IconLoader::Load("view-refresh"), tr("Update all podcasts"), app_->podcast_updater(), &PodcastUpdater::UpdateAllPodcastsNow);
context_menu_->addSeparator();
//context_menu_->addActions(GetPlaylistActions());
context_menu_->addSeparator();
update_selected_action_ = context_menu_->addAction(IconLoader::Load("view-refresh"), tr("Update this podcast"), this, &PodcastService::UpdateSelectedPodcast);
download_selected_action_ = context_menu_->addAction(IconLoader::Load("download"), "", this, &PodcastService::DownloadSelectedEpisode);
info_selected_action_ = context_menu_->addAction(IconLoader::Load("about-info"), tr("Podcast information"), this, &PodcastService::PodcastInfo);
delete_downloaded_action_ = context_menu_->addAction(IconLoader::Load("edit-delete"), tr("Delete downloaded data"), this, &PodcastService::DeleteDownloadedData);
copy_to_device_ = context_menu_->addAction(IconLoader::Load("multimedia-player-ipod-mini-blue"), tr("Copy to device..."), this, QOverload<>::of(&PodcastService::CopyToDevice));
cancel_download_ = context_menu_->addAction(IconLoader::Load("cancel"), tr("Cancel download"), this, QOverload<>::of(&PodcastService::CancelDownload));
remove_selected_action_ = context_menu_->addAction(IconLoader::Load("list-remove"), tr("Unsubscribe"), this, QOverload<>::of(&PodcastService::RemoveSelectedPodcast));
context_menu_->addSeparator();
set_new_action_ = context_menu_->addAction(tr("Mark as new"), this, &PodcastService::SetNew);
set_listened_action_ = context_menu_->addAction(tr("Mark as listened"), this, QOverload<>::of(&PodcastService::SetListened));
context_menu_->addSeparator();
context_menu_->addAction(IconLoader::Load("configure"), tr("Configure podcasts..."), this, &PodcastService::ShowConfig);
copy_to_device_->setDisabled(app_->device_manager()->connected_devices_model()->rowCount() == 0);
connect(app_->device_manager()->connected_devices_model(), &DeviceStateFilterModel::IsEmptyChanged, copy_to_device_, &QAction::setDisabled);
}
selected_episodes_.clear();
selected_podcasts_.clear();
explicitly_selected_podcasts_.clear();
QSet<int> podcast_ids;
#if 0
for (const QModelIndex &index : model()->selected_indexes()) {
switch (index.data(InternetModel::Role_Type).toInt()) {
case Type_Podcast: {
const int id = index.data(Role_Podcast).value<Podcast>().database_id();
if (!podcast_ids.contains(id)) {
selected_podcasts_.append(index);
explicitly_selected_podcasts_.append(index);
podcast_ids.insert(id);
}
break;
}
case Type_Episode: {
selected_episodes_.append(index);
// Add the parent podcast as well.
const QModelIndex parent = index.parent();
const int id = parent.data(Role_Podcast).value<Podcast>().database_id();
if (!podcast_ids.contains(id)) {
selected_podcasts_.append(parent);
podcast_ids.insert(id);
}
break;
}
}
}
#endif
const bool episodes = !selected_episodes_.isEmpty();
const bool podcasts = !selected_podcasts_.isEmpty();
update_selected_action_->setEnabled(podcasts);
remove_selected_action_->setEnabled(podcasts);
set_new_action_->setEnabled(episodes || podcasts);
set_listened_action_->setEnabled(episodes || podcasts);
cancel_download_->setEnabled(episodes || podcasts);
if (selected_episodes_.count() == 1) {
const PodcastEpisode episode = selected_episodes_[0].data(Role_Episode).value<PodcastEpisode>();
const bool downloaded = episode.downloaded();
const bool listened = episode.listened();
download_selected_action_->setEnabled(!downloaded);
delete_downloaded_action_->setEnabled(downloaded);
if (explicitly_selected_podcasts_.isEmpty()) {
set_new_action_->setEnabled(listened);
set_listened_action_->setEnabled(!listened || !episode.listened_date().isValid());
}
}
else {
download_selected_action_->setEnabled(episodes);
delete_downloaded_action_->setEnabled(episodes);
}
if (selected_podcasts_.count() == 1) {
if (selected_episodes_.count() == 1) {
info_selected_action_->setText(tr("Episode information"));
info_selected_action_->setEnabled(true);
}
else {
info_selected_action_->setText(tr("Podcast information"));
info_selected_action_->setEnabled(true);
}
}
else {
info_selected_action_->setText(tr("Podcast information"));
info_selected_action_->setEnabled(false);
}
if (explicitly_selected_podcasts_.isEmpty() && selected_episodes_.isEmpty()) {
PodcastEpisodeList epis = backend_->GetNewDownloadedEpisodes();
set_listened_action_->setEnabled(!epis.isEmpty());
}
if (selected_episodes_.count() > 1) {
download_selected_action_->setText(
tr("Download %n episodes", "", selected_episodes_.count()));
}
else {
download_selected_action_->setText(tr("Download this episode"));
}
//GetAppendToPlaylistAction()->setEnabled(episodes || podcasts);
//GetReplacePlaylistAction()->setEnabled(episodes || podcasts);
//GetOpenInNewPlaylistAction()->setEnabled(episodes || podcasts);
context_menu_->popup(global_pos);
}
void PodcastService::UpdateSelectedPodcast() {
for (const QModelIndex &index : selected_podcasts_) {
app_->podcast_updater()->UpdatePodcastNow(
index.data(Role_Podcast).value<Podcast>());
}
}
void PodcastService::RemoveSelectedPodcast() {
for (const QModelIndex &index : selected_podcasts_) {
backend_->Unsubscribe(index.data(Role_Podcast).value<Podcast>());
}
}
void PodcastService::ReloadSettings() {
InitialLoadSettings();
ClearPodcastList(model_->invisibleRootItem());
PopulatePodcastList(model_->invisibleRootItem());
}
void PodcastService::InitialLoadSettings() {
QSettings s;
s.beginGroup(CollectionSettingsPage::kSettingsGroup);
use_pretty_covers_ = s.value("pretty_covers", true).toBool();
s.endGroup();
s.beginGroup(kSettingsGroup);
hide_listened_ = s.value("hide_listened", false).toBool();
show_episodes_ = s.value("show_episodes", 0).toInt();
s.endGroup();
// TODO(notme): reload the podcast icons that are already loaded?
}
void PodcastService::EnsureAddPodcastDialogCreated() {
add_podcast_dialog_.reset(new AddPodcastDialog(app_));
}
void PodcastService::AddPodcast() {
EnsureAddPodcastDialogCreated();
add_podcast_dialog_->show();
}
void PodcastService::FileCopied(int database_id) {
SetListened(PodcastEpisodeList() << backend_->GetEpisodeById(database_id), true);
}
void PodcastService::SubscriptionAdded(const Podcast &podcast) {
// Ensure the root item is lazy loaded already
LazyLoadRoot();
// The podcast might already be in the list - maybe the LazyLoadRoot() above
// added it.
QStandardItem *item = podcasts_by_database_id_[podcast.database_id()];
if (!item) {
item = CreatePodcastItem(podcast);
model_->appendRow(item);
}
//emit ScrollToIndex(MapToMergedModel(item->index()));
}
void PodcastService::SubscriptionRemoved(const Podcast &podcast) {
QStandardItem *item = podcasts_by_database_id_.take(podcast.database_id());
if (item) {
// Remove any episode ID -> item mappings for the episodes in this podcast.
for (int i = 0; i < item->rowCount(); ++i) {
QStandardItem *episode_item = item->child(i);
const int episode_id = episode_item->data(Role_Episode).value<PodcastEpisode>().database_id();
episodes_by_database_id_.remove(episode_id);
}
// Remove this episode's row
model_->removeRow(item->row());
}
}
void PodcastService::EpisodesAdded(const PodcastEpisodeList &episodes) {
QSet<int> seen_podcast_ids;
for (const PodcastEpisode &episode : episodes) {
const int database_id = episode.podcast_database_id();
QStandardItem *parent = podcasts_by_database_id_[database_id];
if (!parent) continue;
parent->appendRow(CreatePodcastEpisodeItem(episode));
if (!seen_podcast_ids.contains(database_id)) {
// Update the unlistened count text once for each podcast
int unlistened_count = 0;
for (const PodcastEpisode &i : backend_->GetEpisodes(database_id)) {
if (!i.listened()) {
++unlistened_count;
}
}
UpdatePodcastText(parent, unlistened_count);
seen_podcast_ids.insert(database_id);
}
const Podcast podcast = parent->data(Role_Podcast).value<Podcast>();
ReloadPodcast(podcast);
}
}
void PodcastService::EpisodesUpdated(const PodcastEpisodeList &episodes) {
QSet<int> seen_podcast_ids;
QMap<int, Podcast> podcasts_map;
for (const PodcastEpisode &episode : episodes) {
const int podcast_database_id = episode.podcast_database_id();
QStandardItem *item = episodes_by_database_id_[episode.database_id()];
QStandardItem *parent = podcasts_by_database_id_[podcast_database_id];
if (!item || !parent) continue;
// Update the episode data on the item, and update the item's text.
item->setData(QVariant::fromValue(episode), Role_Episode);
UpdateEpisodeText(item);
// Update the parent podcast's text too.
if (!seen_podcast_ids.contains(podcast_database_id)) {
// Update the unlistened count text once for each podcast
int unlistened_count = 0;
for (const PodcastEpisode &i : backend_->GetEpisodes(podcast_database_id)) {
if (!i.listened()) {
++unlistened_count;
}
}
UpdatePodcastText(parent, unlistened_count);
seen_podcast_ids.insert(podcast_database_id);
}
const Podcast podcast = parent->data(Role_Podcast).value<Podcast>();
podcasts_map[podcast.database_id()] = podcast;
}
QList<Podcast> podcast_values = podcasts_map.values();
for (const Podcast &podcast_tmp : podcast_values) {
ReloadPodcast(podcast_tmp);
}
}
void PodcastService::DownloadSelectedEpisode() {
for (const QModelIndex &idx : selected_episodes_) {
app_->podcast_downloader()->DownloadEpisode(idx.data(Role_Episode).value<PodcastEpisode>());
}
}
void PodcastService::PodcastInfo() {
if (selected_podcasts_.isEmpty()) {
// Should never happen.
return;
}
const Podcast podcast = selected_podcasts_[0].data(Role_Podcast).value<Podcast>();
podcast_info_dialog_.reset(new PodcastInfoDialog(app_));
if (selected_episodes_.count() == 1) {
const PodcastEpisode episode = selected_episodes_[0].data(Role_Episode).value<PodcastEpisode>();
podcast_info_dialog_->ShowEpisode(episode, podcast);
}
else {
podcast_info_dialog_->ShowPodcast(podcast);
}
}
void PodcastService::DeleteDownloadedData() {
for (const QModelIndex &idx : selected_episodes_) {
app_->podcast_deleter()->DeleteEpisode(idx.data(Role_Episode).value<PodcastEpisode>());
}
}
void PodcastService::DownloadProgressChanged(const PodcastEpisode &episode, const PodcastDownload::State state, const int percent) {
QStandardItem *item = episodes_by_database_id_[episode.database_id()];
QStandardItem *item2 = podcasts_by_database_id_[episode.podcast_database_id()];
if (!item || !item2) return;
UpdateEpisodeText(item, state, percent);
UpdatePodcastText(item2, state, percent);
}
void PodcastService::ShowConfig() {
//app_->OpenSettingsDialogAtPage(SettingsDialog::Page_Podcasts);
}
void PodcastService::CurrentSongChanged(const Song &metadata) {
// This does two db queries, and we are called on every song change, so run this off the main thread.
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
(void)QtConcurrent::run(&PodcastService::UpdatePodcastListenedStateAsync, this, metadata);
#else
(void)QtConcurrent::run(this, &PodcastService::UpdatePodcastListenedStateAsync, metadata);
#endif
}
void PodcastService::UpdatePodcastListenedStateAsync(const Song &metadata) {
// Check whether this song is one of our podcast episodes.
PodcastEpisode episode = backend_->GetEpisodeByUrlOrLocalUrl(metadata.url());
if (!episode.is_valid()) return;
// Mark it as listened if it's not already
if (!episode.listened() || !episode.listened_date().isValid()) {
episode.set_listened(true);
episode.set_listened_date(QDateTime::currentDateTime());
backend_->UpdateEpisodes(PodcastEpisodeList() << episode);
}
}
void PodcastService::SetNew() {
SetListened(selected_episodes_, explicitly_selected_podcasts_, false);
}
void PodcastService::SetListened() {
if (selected_episodes_.isEmpty() && explicitly_selected_podcasts_.isEmpty()) {
SetListened(backend_->GetNewDownloadedEpisodes(), true);
}
else {
SetListened(selected_episodes_, explicitly_selected_podcasts_, true);
}
}
void PodcastService::SetListened(const PodcastEpisodeList &episodes_list, const bool listened) {
PodcastEpisodeList episodes;
QDateTime current_date_time = QDateTime::currentDateTime();
for (PodcastEpisode episode : episodes_list) {
episode.set_listened(listened);
if (listened) {
episode.set_listened_date(current_date_time);
}
episodes << episode;
}
backend_->UpdateEpisodes(episodes);
}
void PodcastService::SetListened(const QModelIndexList &episode_indexes, const QModelIndexList& podcast_indexes, const bool listened) {
PodcastEpisodeList episodes;
// Get all the episodes from the indexes.
for (const QModelIndex& index : episode_indexes) {
episodes << index.data(Role_Episode).value<PodcastEpisode>();
}
for (const QModelIndex& podcast : podcast_indexes) {
for (int i = 0; i < podcast.model()->rowCount(podcast); ++i) {
const QModelIndex& index = podcast.model()->index(i, 0, podcast);
episodes << index.data(Role_Episode).value<PodcastEpisode>();
}
}
// Update each one with the new state and maybe the listened time.
QDateTime current_date_time = QDateTime::currentDateTime();
for (int i = 0; i < episodes.count(); ++i) {
PodcastEpisode *episode = &episodes[i];
episode->set_listened(listened);
if (listened) {
episode->set_listened_date(current_date_time);
}
}
backend_->UpdateEpisodes(episodes);
}
QModelIndex PodcastService::MapToMergedModel(const QModelIndex &idx) const {
Q_UNUSED(idx)
//return model()->merged_model()->mapFromSource(proxy_->mapFromSource(index));
return QModelIndex();
}
void PodcastService::LazyLoadRoot() {
#if 0
if (root_->data(InternetModel::Role_CanLazyLoad).toBool()) {
root_->setData(false, InternetModel::Role_CanLazyLoad);
LazyPopulate(root_);
}
#endif
}
void PodcastService::SubscribeAndShow(const QVariant &podcast_or_opml) {
if (podcast_or_opml.canConvert<Podcast>()) {
Podcast podcast(podcast_or_opml.value<Podcast>());
backend_->Subscribe(&podcast);
// Lazy load the root item if it hasn't been already
LazyLoadRoot();
QStandardItem *item = podcasts_by_database_id_[podcast.database_id()];
if (item) {
// There will be an item already if this podcast was already there, otherwise it'll be scrolled to when the item is created.
//emit ScrollToIndex(MapToMergedModel(item->index()));
}
}
else if (podcast_or_opml.canConvert<OpmlContainer>()) {
EnsureAddPodcastDialogCreated();
add_podcast_dialog_->ShowWithOpml(podcast_or_opml.value<OpmlContainer>());
}
}
void PodcastService::ReloadPodcast(const Podcast &podcast) {
if (!(hide_listened_ || (show_episodes_ > 0))) {
return;
}
QStandardItem *item = podcasts_by_database_id_[podcast.database_id()];
model_->invisibleRootItem()->removeRow(item->row());
model_->invisibleRootItem()->appendRow(CreatePodcastItem(podcast));
}

View File

@@ -0,0 +1,178 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012-2013, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2013-2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2014, Simeon Bird <sbird@andrew.cmu.edu>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTSERVICE_H
#define PODCASTSERVICE_H
#include <memory>
#include <QMap>
#include <QIcon>
#include <QScopedPointer>
//#include "internet/internetmodel.h"
#include "internet/internetservice.h"
#include "podcastdeleter.h"
#include "podcastdownloader.h"
class QMenu;
class QAction;
class AddPodcastDialog;
class PodcastInfoDialog;
class OrganizeDialog;
class Podcast;
class PodcastBackend;
class PodcastEpisode;
class StandardItemIconLoader;
class QStandardItemModel;
class QStandardItem;
class QSortFilterProxyModel;
class PodcastService : public InternetService {
Q_OBJECT
public:
PodcastService(Application *app, QObject *parent);
~PodcastService();
static const char *kServiceName;
static const char *kSettingsGroup;
enum Type {
Type_AddPodcast = 0,
Type_Podcast,
Type_Episode
};
enum Role {
Role_Podcast = 0,
Role_Episode
};
QStandardItem *CreateRootItem();
void LazyPopulate(QStandardItem *parent);
bool has_initial_load_settings() const { return true; }
void ShowContextMenu(const QPoint &global_pos);
void ReloadSettings();
void InitialLoadSettings();
// Called by SongLoader when the user adds a Podcast URL directly.
// Adds a subscription to the podcast and displays it in the UI.
// If the QVariant contains an OPML file then this displays it in the Add Podcast dialog.
void SubscribeAndShow(const QVariant &podcast_or_opml);
public slots:
void AddPodcast();
void FileCopied(const int database_id);
private slots:
void UpdateSelectedPodcast();
void ReloadPodcast(const Podcast &podcast);
void RemoveSelectedPodcast();
void DownloadSelectedEpisode();
void PodcastInfo();
void DeleteDownloadedData();
void SetNew();
void SetListened();
void ShowConfig();
void SubscriptionAdded(const Podcast &podcast);
void SubscriptionRemoved(const Podcast &podcast);
void EpisodesAdded(const PodcastEpisodeList &episodes);
void EpisodesUpdated(const PodcastEpisodeList &episodes);
void DownloadProgressChanged(const PodcastEpisode &episode, PodcastDownload::State state, int percent);
void CurrentSongChanged(const Song &metadata);
void CopyToDevice();
void CopyToDevice(const PodcastEpisodeList &episodes_list);
void CopyToDevice(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes);
void CancelDownload();
void CancelDownload(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes);
private:
void EnsureAddPodcastDialogCreated();
void UpdatePodcastListenedStateAsync(const Song &metadata);
void PopulatePodcastList(QStandardItem *parent);
void ClearPodcastList(QStandardItem *parent);
void UpdatePodcastText(QStandardItem *item, const int unlistened_count) const;
void UpdateEpisodeText(QStandardItem *item, const PodcastDownload::State state = PodcastDownload::NotDownloading, const int percent = 0);
void UpdatePodcastText(QStandardItem *item, const PodcastDownload::State state = PodcastDownload::NotDownloading, const int percent = 0);
QStandardItem *CreatePodcastItem(const Podcast &podcast);
QStandardItem *CreatePodcastEpisodeItem(const PodcastEpisode &episode);
QModelIndex MapToMergedModel(const QModelIndex &idx) const;
void SetListened(const QModelIndexList &episode_indexes, const QModelIndexList &podcast_indexes, const bool listened);
void SetListened(const PodcastEpisodeList &episodes_list, bool listened);
void LazyLoadRoot();
private:
bool use_pretty_covers_;
bool hide_listened_;
qint64 show_episodes_;
StandardItemIconLoader *icon_loader_;
// The podcast icon
QIcon default_icon_;
// Episodes get different icons depending on their state
QIcon queued_icon_;
QIcon downloading_icon_;
QIcon downloaded_icon_;
PodcastBackend *backend_;
QStandardItemModel *model_;
QSortFilterProxyModel *proxy_;
QMenu *context_menu_;
QAction *update_selected_action_;
QAction *remove_selected_action_;
QAction *download_selected_action_;
QAction *info_selected_action_;
QAction *delete_downloaded_action_;
QAction *set_new_action_;
QAction *set_listened_action_;
QAction *copy_to_device_;
QAction *cancel_download_;
QStandardItem *root_;
std::unique_ptr<OrganizeDialog> organize_dialog_;
QModelIndexList explicitly_selected_podcasts_;
QModelIndexList selected_podcasts_;
QModelIndexList selected_episodes_;
QMap<int, QStandardItem*> podcasts_by_database_id_;
QMap<int, QStandardItem*> episodes_by_database_id_;
std::unique_ptr<AddPodcastDialog> add_podcast_dialog_;
std::unique_ptr<PodcastInfoDialog> podcast_info_dialog_;
};
#endif // PODCASTSERVICE_H

View File

@@ -0,0 +1,101 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QList>
#include <QVariant>
#include <QUrl>
#include <QMimeData>
#include "podcastservicemodel.h"
#include "podcastservice.h"
#include "playlist/songmimedata.h"
PodcastServiceModel::PodcastServiceModel(QObject* parent) : QStandardItemModel(parent) {}
QMimeData* PodcastServiceModel::mimeData(const QModelIndexList &indexes) const {
SongMimeData *data = new SongMimeData;
QList<QUrl> urls;
#if 0
for (const QModelIndex& index : indexes) {
switch (index.data(InternetModel::Role_Type).toInt()) {
case PodcastService::Type_Episode:
MimeDataForEpisode(index, data, &urls);
break;
case PodcastService::Type_Podcast:
MimeDataForPodcast(index, data, &urls);
break;
}
}
#endif
data->setUrls(urls);
return data;
}
void PodcastServiceModel::MimeDataForEpisode(const QModelIndex &idx, SongMimeData *data, QList<QUrl>* urls) const {
QVariant episode_variant = idx.data(PodcastService::Role_Episode);
if (!episode_variant.isValid()) return;
PodcastEpisode episode(episode_variant.value<PodcastEpisode>());
// Get the podcast from the index's parent
Podcast podcast;
QVariant podcast_variant = idx.parent().data(PodcastService::Role_Podcast);
if (podcast_variant.isValid()) {
podcast = podcast_variant.value<Podcast>();
}
Song song = episode.ToSong(podcast);
data->songs << song;
*urls << song.url();
}
void PodcastServiceModel::MimeDataForPodcast(const QModelIndex &idx, SongMimeData *data, QList<QUrl> *urls) const {
// Get the podcast
Podcast podcast;
QVariant podcast_variant = idx.data(PodcastService::Role_Podcast);
if (podcast_variant.isValid()) {
podcast = podcast_variant.value<Podcast>();
}
// Add each child episode
const int children = idx.model()->rowCount(idx);
for (int i = 0; i < children; ++i) {
QVariant episode_variant = idx.model()->index(i, 0, idx).data(PodcastService::Role_Episode);
if (!episode_variant.isValid()) continue;
PodcastEpisode episode(episode_variant.value<PodcastEpisode>());
Song song = episode.ToSong(podcast);
data->songs << song;
*urls << song.url();
}
}

View File

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

View File

@@ -0,0 +1,194 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include "podcastupdater.h"
#include <QObject>
#include <QSet>
#include <QList>
#include <QUrl>
#include <QDateTime>
#include <QTimer>
#include <QSettings>
#include "core/application.h"
#include "core/logging.h"
#include "core/timeconstants.h"
#include "podcastbackend.h"
#include "podcasturlloader.h"
const char *PodcastUpdater::kSettingsGroup = "Podcasts";
PodcastUpdater::PodcastUpdater(Application *app, QObject *parent)
: QObject(parent),
app_(app),
update_interval_secs_(0),
update_timer_(new QTimer(this)),
loader_(new PodcastUrlLoader(this)),
pending_replies_(0) {
update_timer_->setSingleShot(true);
QObject::connect(app_, &Application::SettingsChanged, this, &PodcastUpdater::ReloadSettings);
QObject::connect(update_timer_, &QTimer::timeout, this, &PodcastUpdater::UpdateAllPodcastsNow);
QObject::connect(app_->podcast_backend(), &PodcastBackend::SubscriptionAdded, this, &PodcastUpdater::SubscriptionAdded);
ReloadSettings();
}
void PodcastUpdater::ReloadSettings() {
QSettings s;
s.beginGroup(kSettingsGroup);
last_full_update_ = s.value("last_full_update").toDateTime();
update_interval_secs_ = s.value("update_interval_secs").toInt();
s.endGroup();
RestartTimer();
}
void PodcastUpdater::SaveSettings() {
QSettings s;
s.beginGroup(kSettingsGroup);
s.setValue("last_full_update", last_full_update_);
s.endGroup();
}
void PodcastUpdater::RestartTimer() {
// Stop any existing timer
update_timer_->stop();
if (pending_replies_ > 0) {
// We're still waiting for replies from the last update - don't do anything.
return;
}
if (update_interval_secs_ > 0) {
if (!last_full_update_.isValid()) {
// Updates are enabled and we've never updated before. Do it now.
qLog(Info) << "Updating podcasts for the first time";
UpdateAllPodcastsNow();
}
else {
const QDateTime next_update = last_full_update_.addSecs(update_interval_secs_);
const int secs_until_next_update = QDateTime::currentDateTime().secsTo(next_update);
if (secs_until_next_update < 0) {
qLog(Info) << "Updating podcasts" << (-secs_until_next_update) << "seconds late";
UpdateAllPodcastsNow();
}
else {
qLog(Info) << "Updating podcasts at" << next_update << "(in" << secs_until_next_update << "seconds)";
update_timer_->start(secs_until_next_update * kMsecPerSec);
}
}
}
}
void PodcastUpdater::SubscriptionAdded(const Podcast& podcast) {
// Only update a new podcast immediately if it doesn't have an episode list.
// We assume that the episode list has already been fetched recently otherwise.
if (podcast.episodes().isEmpty()) {
UpdatePodcastNow(podcast);
}
}
void PodcastUpdater::UpdatePodcastNow(const Podcast& podcast) {
PodcastUrlLoaderReply *reply = loader_->Load(podcast.url());
QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply, podcast]() { PodcastLoaded(reply, podcast, false); });
}
void PodcastUpdater::UpdateAllPodcastsNow() {
PodcastList podcasts = app_->podcast_backend()->GetAllSubscriptions();
for (const Podcast &podcast : podcasts) {
PodcastUrlLoaderReply *reply = loader_->Load(podcast.url());
QObject::connect(reply, &PodcastUrlLoaderReply::Finished, this, [this, reply, podcast]() { PodcastLoaded(reply, podcast, true); });
++pending_replies_;
}
}
void PodcastUpdater::PodcastLoaded(PodcastUrlLoaderReply *reply, const Podcast& podcast, bool one_of_many) {
reply->deleteLater();
if (one_of_many) {
--pending_replies_;
if (pending_replies_ == 0) {
// This was the last reply we were waiting for. Save this time as being
// the last successful update and restart the timer.
last_full_update_ = QDateTime::currentDateTime();
SaveSettings();
RestartTimer();
}
}
if (!reply->is_success()) {
qLog(Warning) << "Error fetching podcast at" << podcast.url() << ":" << reply->error_text();
return;
}
if (reply->result_type() != PodcastUrlLoaderReply::Type_Podcast) {
qLog(Warning) << "The URL" << podcast.url() << "no longer contains a podcast";
return;
}
// Get the episode URLs we had for this podcast already.
QSet<QUrl> existing_urls;
for (const PodcastEpisode &episode :
app_->podcast_backend()->GetEpisodes(podcast.database_id())) {
existing_urls.insert(episode.url());
}
// Add any new episodes
PodcastEpisodeList new_episodes;
PodcastList reply_podcasts = reply->podcast_results();
for (const Podcast &reply_podcast : reply_podcasts) {
PodcastEpisodeList episodes = reply_podcast.episodes();
for (const PodcastEpisode &episode : episodes) {
if (!existing_urls.contains(episode.url())) {
PodcastEpisode episode_copy(episode);
episode_copy.set_podcast_database_id(podcast.database_id());
new_episodes.append(episode_copy);
}
}
}
app_->podcast_backend()->AddEpisodes(&new_episodes);
qLog(Info) << "Added" << new_episodes.count() << "new episodes for" << podcast.url();
}

View File

@@ -0,0 +1,71 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTUPDATER_H
#define PODCASTUPDATER_H
#include <QObject>
#include <QDateTime>
class Application;
class Podcast;
class PodcastUrlLoader;
class PodcastUrlLoaderReply;
class QTimer;
// Responsible for updating podcasts when they're first subscribed to, and then updating them at regular intervals afterwards.
class PodcastUpdater : public QObject {
Q_OBJECT
public:
explicit PodcastUpdater(Application *app, QObject *parent = nullptr);
public slots:
void UpdateAllPodcastsNow();
void UpdatePodcastNow(const Podcast &podcast);
private slots:
void ReloadSettings();
void SubscriptionAdded(const Podcast &podcast);
void PodcastLoaded(PodcastUrlLoaderReply *reply, const Podcast &podcast, const bool one_of_many);
private:
void RestartTimer();
void SaveSettings();
private:
static const char *kSettingsGroup;
Application *app_;
QDateTime last_full_update_;
int update_interval_secs_;
QTimer *update_timer_;
PodcastUrlLoader *loader_;
int pending_replies_;
};
#endif // PODCASTUPDATER_H

View File

@@ -0,0 +1,250 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#include <QObject>
#include <QUrl>
#include <QUrlQuery>
#include <QNetworkRequest>
#include <QNetworkReply>
#include "core/logging.h"
#include "core/networkaccessmanager.h"
#include "core/utilities.h"
#include "podcasturlloader.h"
#include "podcastparser.h"
const int PodcastUrlLoader::kMaxRedirects = 5;
PodcastUrlLoader::PodcastUrlLoader(QObject* parent)
: QObject(parent),
network_(new NetworkAccessManager(this)),
parser_(new PodcastParser),
html_link_re_("<link (.*)>"),
html_link_rel_re_("rel\\s*=\\s*['\"]?\\s*alternate"),
html_link_type_re_("type\\s*=\\s*['\"]?([^'\" ]+)"),
html_link_href_re_("href\\s*=\\s*['\"]?([^'\" ]+)") {
//html_link_re_.setMinimal(true);
//html_link_re_.setCaseSensitivity(Qt::CaseInsensitive);
}
PodcastUrlLoader::~PodcastUrlLoader() { delete parser_; }
QUrl PodcastUrlLoader::FixPodcastUrl(const QString& url_text) {
QString url_text_copy(url_text.trimmed());
// Thanks gpodder!
QuickPrefixList quick_prefixes = QuickPrefixList()
<< QuickPrefix("fb:", "http://feeds.feedburner.com/%1")
<< QuickPrefix("yt:", "https://www.youtube.com/rss/user/%1/videos.rss")
<< QuickPrefix("sc:", "https://soundcloud.com/%1")
<< QuickPrefix("fm4od:", "http://onapp1.orf.at/webcam/fm4/fod/%1.xspf")
<< QuickPrefix("ytpl:", "https://gdata.youtube.com/feeds/api/playlists/%1");
// Check if it matches one of the quick prefixes.
for (QuickPrefixList::const_iterator it = quick_prefixes.constBegin(); it != quick_prefixes.constEnd(); ++it) {
if (url_text_copy.startsWith(it->first)) {
url_text_copy = it->second.arg(url_text_copy.mid(it->first.length()));
}
}
if (!url_text_copy.contains("://")) {
url_text_copy.prepend("http://");
}
return FixPodcastUrl(QUrl(url_text_copy));
}
QUrl PodcastUrlLoader::FixPodcastUrl(const QUrl& url_orig) {
QUrl url(url_orig);
QUrlQuery url_query(url);
// Replace schemes
if (url.scheme().isEmpty() || url.scheme() == "feed" || url.scheme() == "itpc" || url.scheme() == "itms") {
url.setScheme("http");
}
else if (url.scheme() == "zune" && url.host() == "subscribe" &&
!url_query.queryItems().isEmpty()) {
url = QUrl(url_query.queryItems()[0].second);
}
return url;
}
PodcastUrlLoaderReply* PodcastUrlLoader::Load(const QString& url_text) {
return Load(FixPodcastUrl(url_text));
}
PodcastUrlLoaderReply* PodcastUrlLoader::Load(const QUrl& url) {
// Create a reply
PodcastUrlLoaderReply* reply = new PodcastUrlLoaderReply(url, this);
// Create a state object to track this request
RequestState* state = new RequestState;
state->redirects_remaining_ = kMaxRedirects + 1;
state->reply_ = reply;
// Start the first request
NextRequest(url, state);
return reply;
}
void PodcastUrlLoader::SendErrorAndDelete(const QString& error_text, RequestState* state) {
state->reply_->SetFinished(error_text);
delete state;
}
void PodcastUrlLoader::NextRequest(const QUrl& url, RequestState* state) {
// Stop the request if there have been too many redirects already.
if (state->redirects_remaining_-- == 0) {
SendErrorAndDelete(tr("Too many redirects"), state);
return;
}
qLog(Debug) << "Loading URL" << url;
QNetworkRequest req(url);
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork);
QNetworkReply* network_reply = network_->get(req);
QObject::connect(network_reply, &QNetworkReply::finished, this, [this, state, network_reply]() { RequestFinished(state, network_reply); });
}
void PodcastUrlLoader::RequestFinished(RequestState* state, QNetworkReply* reply) {
reply->deleteLater();
if (reply->attribute(QNetworkRequest::RedirectionTargetAttribute).isValid()) {
const QUrl next_url = reply->url().resolved(reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl());
NextRequest(next_url, state);
return;
}
// Check for errors.
if (reply->error() != QNetworkReply::NoError) {
SendErrorAndDelete(reply->errorString(), state);
return;
}
const QVariant http_status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
if (http_status.isValid() && http_status.toInt() != 200) {
SendErrorAndDelete(
QString("HTTP %1: %2").arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) .toString(), reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString()),
state);
return;
}
// Check the mime type.
const QString content_type = reply->header(QNetworkRequest::ContentTypeHeader).toString();
if (parser_->SupportsContentType(content_type)) {
const QVariant ret = parser_->Load(reply, reply->url());
if (ret.canConvert<Podcast>()) {
state->reply_->SetFinished(PodcastList() << ret.value<Podcast>());
}
else if (ret.canConvert<OpmlContainer>()) {
state->reply_->SetFinished(ret.value<OpmlContainer>());
}
else {
SendErrorAndDelete(tr("Failed to parse the XML for this RSS feed"),
state);
return;
}
delete state;
return;
}
else if (content_type.contains("text/html")) {
// I don't want a full HTML parser here, so do this the dirty way.
const QString page_text = QString::fromUtf8(reply->readAll());
//int pos = 0;
#if 0
while ((pos = html_link_re_.indexIn(page_text, pos)) != -1) {
const QString link = html_link_re_.cap(1).toLower();
pos += html_link_re_.matchedLength();
if (html_link_rel_re_.indexIn(link) == -1 ||
html_link_type_re_.indexIn(link) == -1 ||
html_link_href_re_.indexIn(link) == -1) {
continue;
}
const QString link_type = html_link_type_re_.cap(1);
const QString href = Utilities::DecodeHtmlEntities(html_link_href_re_.cap(1));
if (parser_->supported_mime_types().contains(link_type)) {
NextRequest(QUrl(href), state);
return;
}
}
#endif
SendErrorAndDelete(tr("HTML page did not contain any RSS feeds"), state);
}
else {
SendErrorAndDelete(tr("Unknown content-type") + ": " + content_type, state);
}
}
PodcastUrlLoaderReply::PodcastUrlLoaderReply(const QUrl& url, QObject* parent)
: QObject(parent), url_(url), finished_(false) {}
void PodcastUrlLoaderReply::SetFinished(const PodcastList& results) {
result_type_ = Type_Podcast;
podcast_results_ = results;
finished_ = true;
emit Finished(true);
}
void PodcastUrlLoaderReply::SetFinished(const OpmlContainer& results) {
result_type_ = Type_Opml;
opml_results_ = results;
finished_ = true;
emit Finished(true);
}
void PodcastUrlLoaderReply::SetFinished(const QString& error_text) {
error_text_ = error_text;
finished_ = true;
emit Finished(false);
}

View File

@@ -0,0 +1,119 @@
/*
* Strawberry Music Player
* This file was part of Clementine.
* Copyright 2012, David Sansome <me@davidsansome.com>
* Copyright 2012, 2014, John Maguire <john.maguire@gmail.com>
* Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
* Copyright 2019-2021, Jonas Kvinge <jonas@jkvinge.net>
*
* Strawberry is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Strawberry is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
*
*/
#ifndef PODCASTURLLOADER_H
#define PODCASTURLLOADER_H
#include <QObject>
#include <QRegularExpression>
#include "opmlcontainer.h"
#include "podcast.h"
class PodcastParser;
class QNetworkAccessManager;
class QNetworkReply;
class PodcastUrlLoaderReply : public QObject {
Q_OBJECT
public:
PodcastUrlLoaderReply(const QUrl& url, QObject* parent);
enum ResultType { Type_Podcast,
Type_Opml };
const QUrl& url() const { return url_; }
bool is_finished() const { return finished_; }
bool is_success() const { return error_text_.isEmpty(); }
const QString& error_text() const { return error_text_; }
ResultType result_type() const { return result_type_; }
const PodcastList& podcast_results() const { return podcast_results_; }
const OpmlContainer& opml_results() const { return opml_results_; }
void SetFinished(const QString& error_text);
void SetFinished(const PodcastList& results);
void SetFinished(const OpmlContainer& results);
signals:
void Finished(bool success);
private:
QUrl url_;
bool finished_;
QString error_text_;
ResultType result_type_;
PodcastList podcast_results_;
OpmlContainer opml_results_;
};
class PodcastUrlLoader : public QObject {
Q_OBJECT
public:
explicit PodcastUrlLoader(QObject* parent = nullptr);
~PodcastUrlLoader();
static const int kMaxRedirects;
PodcastUrlLoaderReply* Load(const QString& url_text);
PodcastUrlLoaderReply* Load(const QUrl& url);
// Both the FixPodcastUrl functions replace common podcatcher URL schemes
// like itpc:// or zune:// with their http:// equivalents. The QString
// overload also cleans up user-entered text a bit - stripping whitespace and
// applying shortcuts like sc:tag.
static QUrl FixPodcastUrl(const QString& url_text);
static QUrl FixPodcastUrl(const QUrl& url);
private:
struct RequestState {
int redirects_remaining_;
PodcastUrlLoaderReply* reply_;
};
typedef QPair<QString, QString> QuickPrefix;
typedef QList<QuickPrefix> QuickPrefixList;
private slots:
void RequestFinished(RequestState* state, QNetworkReply* reply);
private:
void SendErrorAndDelete(const QString& error_text, RequestState* state);
void NextRequest(const QUrl& url, RequestState* state);
private:
QNetworkAccessManager* network_;
PodcastParser* parser_;
QRegularExpression html_link_re_;
QRegularExpression whitespace_re_;
QRegularExpression html_link_rel_re_;
QRegularExpression html_link_type_re_;
QRegularExpression html_link_href_re_;
};
#endif // PODCASTURLLOADER_H

View File

@@ -0,0 +1,146 @@
/* This file is part of Clementine.
Copyright 2012, David Sansome <me@davidsansome.com>
Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
Copyright 2014, John Maguire <john.maguire@gmail.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#include "podcastsettingspage.h"
#include <QFileDialog>
#include <QSettings>
#include "core/application.h"
#include "core/timeconstants.h"
#include "core/iconloader.h"
#include "collection/collectiondirectorymodel.h"
#include "collection/collectionmodel.h"
#include "podcasts/gpoddersync.h"
#include "podcasts/podcastdownloader.h"
#include "settingsdialog.h"
#include "ui_podcastsettingspage.h"
const char* PodcastSettingsPage::kSettingsGroup = "Podcasts";
PodcastSettingsPage::PodcastSettingsPage(SettingsDialog* dialog)
: SettingsPage(dialog), ui_(new Ui_PodcastSettingsPage) {
ui_->setupUi(this);
setWindowIcon(IconLoader::Load("podcast"));
connect(ui_->login, SIGNAL(clicked()), SLOT(LoginClicked()));
connect(ui_->login_state, SIGNAL(LoginClicked()), SLOT(LoginClicked()));
connect(ui_->login_state, SIGNAL(LogoutClicked()), SLOT(LogoutClicked()));
connect(ui_->download_dir_browse, SIGNAL(clicked()),
SLOT(DownloadDirBrowse()));
GPodderSync* gsync = dialog->app()->gpodder_sync();
connect(gsync, SIGNAL(LoginSuccess()), SLOT(GpodderLoginSuccess()));
connect(gsync, SIGNAL(LoginFailure(const QString&)), SLOT(GpodderLoginFailure(const QString&)));
ui_->login_state->AddCredentialField(ui_->username);
ui_->login_state->AddCredentialField(ui_->device_name);
ui_->login_state->AddCredentialField(ui_->password);
ui_->login_state->AddCredentialGroup(ui_->login_group);
ui_->check_interval->setItemData(0, 0); // manually
ui_->check_interval->setItemData(1, 10 * 60); // 10 minutes
ui_->check_interval->setItemData(2, 20 * 60); // 20 minutes
ui_->check_interval->setItemData(3, 30 * 60); // 30 minutes
ui_->check_interval->setItemData(4, 60 * 60); // 1 hour
ui_->check_interval->setItemData(5, 2 * 60 * 60); // 2 hours
ui_->check_interval->setItemData(6, 6 * 60 * 60); // 6 hours
ui_->check_interval->setItemData(7, 12 * 60 * 60); // 12 hours
}
PodcastSettingsPage::~PodcastSettingsPage() { delete ui_; }
void PodcastSettingsPage::Load() {
QSettings s;
s.beginGroup(kSettingsGroup);
const int update_interval = s.value("update_interval_secs", 0).toInt();
ui_->check_interval->setCurrentIndex(
ui_->check_interval->findData(update_interval));
const QString default_download_dir =
dialog()->app()->podcast_downloader()->DefaultDownloadDir();
ui_->download_dir->setText(QDir::toNativeSeparators(
s.value("download_dir", default_download_dir).toString()));
ui_->auto_download->setChecked(s.value("auto_download", false).toBool());
ui_->hide_listened->setChecked(s.value("hide_listened", false).toBool());
ui_->delete_after->setValue(s.value("delete_after", 0).toInt() / kSecsPerDay);
ui_->show_episodes->setValue(s.value("show_episodes", 0).toInt());
ui_->username->setText(s.value("gpodder_username").toString());
ui_->device_name->setText(
s.value("gpodder_device_name", GPodderSync::DefaultDeviceName())
.toString());
if (dialog()->app()->gpodder_sync()->is_logged_in()) {
ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn,
ui_->username->text());
}
else {
ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut);
}
}
void PodcastSettingsPage::Save() {
QSettings s;
s.beginGroup(kSettingsGroup);
s.setValue("update_interval_secs", ui_->check_interval->itemData(ui_->check_interval->currentIndex()));
s.setValue("download_dir",
QDir::fromNativeSeparators(ui_->download_dir->text()));
s.setValue("auto_download", ui_->auto_download->isChecked());
s.setValue("hide_listened", ui_->hide_listened->isChecked());
s.setValue("delete_after", ui_->delete_after->value() * kSecsPerDay);
s.setValue("show_episodes", ui_->show_episodes->value());
s.setValue("gpodder_device_name", ui_->device_name->text());
}
void PodcastSettingsPage::LoginClicked() {
ui_->login_state->SetLoggedIn(LoginStateWidget::LoginInProgress);
dialog()->app()->gpodder_sync()->Login(
ui_->username->text(), ui_->password->text(), ui_->device_name->text());
}
void PodcastSettingsPage::GpodderLoginSuccess() {
ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedIn,
ui_->username->text());
ui_->login_state->SetAccountTypeVisible(false);
}
void PodcastSettingsPage::GpodderLoginFailure(const QString& error) {
ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut,
ui_->username->text());
ui_->login_state->SetAccountTypeVisible(true);
ui_->login_state->SetAccountTypeText(tr("Login failed") + ": " + error);
}
void PodcastSettingsPage::LogoutClicked() {
ui_->login_state->SetLoggedIn(LoginStateWidget::LoggedOut);
ui_->password->clear();
dialog()->app()->gpodder_sync()->Logout();
}
void PodcastSettingsPage::DownloadDirBrowse() {
QString directory = QFileDialog::getExistingDirectory(
this, tr("Choose podcast download directory"), ui_->download_dir->text());
if (directory.isEmpty()) return;
ui_->download_dir->setText(QDir::toNativeSeparators(directory));
}

View File

@@ -0,0 +1,52 @@
/* This file is part of Clementine.
Copyright 2012, David Sansome <me@davidsansome.com>
Copyright 2014, John Maguire <john.maguire@gmail.com>
Copyright 2014, Krzysztof Sobiecki <sobkas@gmail.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef PODCASTSETTINGSPAGE_H
#define PODCASTSETTINGSPAGE_H
#include "settingspage.h"
class Ui_PodcastSettingsPage;
class PodcastSettingsPage : public SettingsPage {
Q_OBJECT
public:
explicit PodcastSettingsPage(SettingsDialog* dialog);
~PodcastSettingsPage();
static const char* kSettingsGroup;
void Load();
void Save();
private slots:
void LoginClicked();
void LogoutClicked();
void GpodderLoginSuccess();
void GpodderLoginFailure(const QString& error);
void DownloadDirBrowse();
private:
Ui_PodcastSettingsPage* ui_;
};
#endif // PODCASTSETTINGSPAGE_H

View File

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

View File

@@ -0,0 +1,168 @@
/* This file is part of Clementine.
Copyright 2010, David Sansome <me@davidsansome.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#include "widgetfadehelper.h"
#include <QPainter>
#include <QResizeEvent>
#include <QTimeLine>
#include <QtDebug>
#include "core/qt_blurimage.h"
const int WidgetFadeHelper::kLoadingPadding = 9;
const int WidgetFadeHelper::kLoadingBorderRadius = 10;
WidgetFadeHelper::WidgetFadeHelper(QWidget* parent, const int msec)
: QWidget(parent),
parent_(parent),
blur_timeline_(new QTimeLine(msec, this)),
fade_timeline_(new QTimeLine(msec, this)) {
parent->installEventFilter(this);
connect(blur_timeline_, SIGNAL(valueChanged(qreal)), SLOT(update()));
connect(fade_timeline_, SIGNAL(valueChanged(qreal)), SLOT(update()));
connect(fade_timeline_, SIGNAL(finished()), SLOT(FadeFinished()));
hide();
}
bool WidgetFadeHelper::eventFilter(QObject* obj, QEvent* event) {
// We're only interested in our parent's resize events
if (obj != parent_ || event->type() != QEvent::Resize) return false;
// Don't care if we're hidden
if (!isVisible()) return false;
QResizeEvent* re = static_cast<QResizeEvent*>(event);
if (re->oldSize() == re->size()) {
// Ignore phoney resize events
return false;
}
// Get a new capture of the parent
hide();
CaptureParent();
show();
return false;
}
void WidgetFadeHelper::StartBlur() {
CaptureParent();
// Cover the parent
raise();
show();
// Start the timeline
blur_timeline_->stop();
blur_timeline_->start();
setAttribute(Qt::WA_TransparentForMouseEvents, false);
}
void WidgetFadeHelper::CaptureParent() {
// Take a "screenshot" of the window
original_pixmap_ = parent_->grab();
QImage original_image = original_pixmap_.toImage();
// Blur it
QImage blurred(original_image.size(), QImage::Format_ARGB32_Premultiplied);
blurred.fill(Qt::transparent);
QPainter blur_painter(&blurred);
blur_painter.save();
qt_blurImage(&blur_painter, original_image, 10.0, true, false);
blur_painter.restore();
// Draw some loading text over the top
QFont loading_font(font());
loading_font.setBold(true);
QFontMetrics loading_font_metrics(loading_font);
const QString loading_text = tr("Loading...");
const QSize loading_size(
kLoadingPadding * 2 + loading_font_metrics.width(loading_text),
kLoadingPadding * 2 + loading_font_metrics.height());
const QRect loading_rect((blurred.width() - loading_size.width()) / 2, 100,
loading_size.width(), loading_size.height());
blur_painter.setRenderHint(QPainter::Antialiasing);
blur_painter.setRenderHint(QPainter::HighQualityAntialiasing);
blur_painter.translate(0.5, 0.5);
blur_painter.setPen(QColor(200, 200, 200, 255));
blur_painter.setBrush(QColor(200, 200, 200, 192));
blur_painter.drawRoundedRect(loading_rect, kLoadingBorderRadius,
kLoadingBorderRadius);
blur_painter.setPen(palette().brush(QPalette::Text).color());
blur_painter.setFont(loading_font);
blur_painter.drawText(loading_rect.translated(-1, -1), Qt::AlignCenter,
loading_text);
blur_painter.translate(-0.5, -0.5);
blur_painter.end();
blurred_pixmap_ = QPixmap::fromImage(blurred);
resize(parent_->size());
}
void WidgetFadeHelper::StartFade() {
if (blur_timeline_->state() == QTimeLine::Running) {
// Blur timeline is still running, so we need render the current state
// into a new pixmap.
QPixmap pixmap(original_pixmap_);
QPainter painter(&pixmap);
painter.setOpacity(blur_timeline_->currentValue());
painter.drawPixmap(0, 0, blurred_pixmap_);
painter.end();
blurred_pixmap_ = pixmap;
}
blur_timeline_->stop();
original_pixmap_ = QPixmap();
// Start the timeline
fade_timeline_->stop();
fade_timeline_->start();
setAttribute(Qt::WA_TransparentForMouseEvents, true);
}
void WidgetFadeHelper::paintEvent(QPaintEvent*) {
QPainter p(this);
if (fade_timeline_->state() != QTimeLine::Running) {
// We're fading in the blur
p.drawPixmap(0, 0, original_pixmap_);
p.setOpacity(blur_timeline_->currentValue());
} else {
// Fading out the blur into the new image
p.setOpacity(1.0 - fade_timeline_->currentValue());
}
p.drawPixmap(0, 0, blurred_pixmap_);
}
void WidgetFadeHelper::FadeFinished() {
hide();
original_pixmap_ = QPixmap();
blurred_pixmap_ = QPixmap();
}

View File

@@ -0,0 +1,57 @@
/* This file is part of Clementine.
Copyright 2010, David Sansome <me@davidsansome.com>
Clementine is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Clementine is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Clementine. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef WIDGETFADEHELPER_H
#define WIDGETFADEHELPER_H
#include <QWidget>
class QTimeLine;
class WidgetFadeHelper : public QWidget {
Q_OBJECT
public:
WidgetFadeHelper(QWidget* parent, const int msec = 500);
public slots:
void StartBlur();
void StartFade();
protected:
void paintEvent(QPaintEvent*);
bool eventFilter(QObject* obj, QEvent* event);
private slots:
void FadeFinished();
private:
void CaptureParent();
private:
static const int kLoadingPadding;
static const int kLoadingBorderRadius;
QWidget* parent_;
QTimeLine* blur_timeline_;
QTimeLine* fade_timeline_;
QPixmap original_pixmap_;
QPixmap blurred_pixmap_;
};
#endif // WIDGETFADEHELPER_H