mpris-remote: Support for fetching album art

Implementation of sending album art from phone to PC.

Complementary MR for the android side: https://invent.kde.org/network/kdeconnect-android/-/merge_requests/353

Fixes: https://bugs.kde.org/show_bug.cgi?id=422136
This commit is contained in:
Krut Patel 2024-07-31 16:39:28 +00:00 committed by Simon Redman
parent ce92c5beba
commit f2e506e059
8 changed files with 304 additions and 0 deletions

View file

@ -2,6 +2,7 @@ kdeconnect_add_plugin(kdeconnect_mprisremote)
target_sources(kdeconnect_mprisremote PRIVATE
mprisremoteplugin.cpp
albumart_cache.cpp
mprisremoteplayer.cpp
mprisremoteplayermediaplayer2.cpp
mprisremoteplayermediaplayer2player.cpp
@ -9,4 +10,5 @@ target_sources(kdeconnect_mprisremote PRIVATE
target_link_libraries(kdeconnect_mprisremote
kdeconnectcore
Qt::DBus
Qt::Core # for hash
)

View file

@ -0,0 +1,121 @@
#include "albumart_cache.h"
#include <QDir>
#include <QReadLocker>
#include <QStandardPaths>
#include "filetransferjob.h"
#include "kjob.h"
#include "mprisremoteplugin.h"
#include "plugin_mprisremote_debug.h"
// TODO: Not sure where to put such utils
constexpr std::size_t operator""_MB(unsigned long long v)
{
return 1024u * 1024u * v;
}
static constexpr qsizetype CACHE_SIZE = 5_MB;
AlbumArtCache::AlbumArtCache()
{
m_cachedFiles.setMaxCost(CACHE_SIZE);
m_cacheDir = QDir{QStandardPaths::writableLocation(QStandardPaths::CacheLocation).append(QStringLiteral("/kdeconnect/albumart"))};
if (!m_cacheDir.exists()) {
m_cacheDir.mkpath(QStringLiteral("."));
} else {
// clear the directory
// TODO: Better thing to do would be to re-populate the m_cachedFiles
qDebug() << "Clearing existing entries" << m_cacheDir.entryList(QDir::Files).size();
for (auto &file : m_cacheDir.entryList(QDir::Files)) {
m_cacheDir.remove(file);
}
}
}
AlbumArtCache::~AlbumArtCache() = default;
AlbumArtCache *AlbumArtCache::instance()
{
static auto *s_albumArtCache = new AlbumArtCache();
return s_albumArtCache;
}
AlbumArtCache::IndexItem AlbumArtCache::getAlbumArt(const QString &remoteUrl, MprisRemotePlugin *plugin, const QString &player)
{
if (remoteUrl.isEmpty()) {
// Can't fetch an empty remoteUrl. Do we want to add a separate status for this?
return IndexItem{IndexItem::FAILED};
}
QReadLocker locker{&instance()->m_cacheLock};
IndexItem *item = instance()->m_cachedFiles.object(remoteUrl);
if (item != nullptr) {
if (item->fetchStatus == IndexItem::SUCCESS) {
qCDebug(KDECONNECT_PLUGIN_MPRISREMOTE) << "album art already present" << remoteUrl << "at" << item->file->localPath;
if (!item->file->localPath.isLocalFile()) {
qCWarning(KDECONNECT_PLUGIN_MPRISREMOTE) << "No file for cached art!" << item->file->localPath;
return IndexItem{IndexItem::FAILED};
}
}
return *item;
} else {
// TODO: First check if we are already fetching it
// fetch the album art from plugin
plugin->requestAlbumArt(player, remoteUrl);
return IndexItem{IndexItem::FETCHING};
}
}
void AlbumArtCache::handleAlbumArt(const NetworkPacket &np)
{
auto remoteUrl = np.get<QString>(QStringLiteral("albumArtUrl"));
if (np.payloadSize() > CACHE_SIZE) {
qCWarning(KDECONNECT_PLUGIN_MPRISREMOTE) << "Art is too big! Ignoring.";
return;
}
if (remoteUrl.isEmpty()) {
qCWarning(KDECONNECT_PLUGIN_MPRISREMOTE) << "No url with art";
return;
}
auto player = np.get<QString>(QStringLiteral("player"));
// TODO: Track in-flight requests and reject if not present
{
QReadLocker locker{&instance()->m_cacheLock};
auto *entry = m_cachedFiles.object(remoteUrl);
if (entry != nullptr) {
if (entry->fetchStatus == IndexItem::SUCCESS) {
qCDebug(KDECONNECT_PLUGIN_MPRISREMOTE) << "Cache hit" << entry->file->localPath.fileName() << "for" << remoteUrl;
Q_EMIT albumArtFetched(player, remoteUrl, entry->file);
return;
} else {
// fetch again
}
}
}
// FIXME: better local file path
auto filename = QStringLiteral("%1.jpg").arg(qHash(remoteUrl));
auto localUrl = QUrl::fromLocalFile(m_cacheDir.filePath(filename));
auto *job = np.createPayloadTransferJob(localUrl);
connect(job, &FileTransferJob::result, this, [this, job, remoteUrl, localUrl, player]() {
if (job->error()) {
// TODO: Handle error, retry?
qCWarning(KDECONNECT_PLUGIN_MPRISREMOTE) << "art transfer error" << remoteUrl << "to" << localUrl << job->errorString();
return;
}
auto fileSize = static_cast<qsizetype>(job->totalAmount(KJob::Unit::Bytes));
qCInfo(KDECONNECT_PLUGIN_MPRISREMOTE) << "Finished art transfer! from" << remoteUrl << "to" << localUrl << "size" << fileSize;
auto *indexItem = new IndexItem{localUrl};
auto localPath = indexItem->file;
QWriteLocker locker{&instance()->m_cacheLock};
m_cachedFiles.insert(remoteUrl, indexItem, fileSize);
if (!QFile{localUrl.toLocalFile()}.exists()) {
qCWarning(KDECONNECT_PLUGIN_MPRISREMOTE) << "File doesn't exist" << localUrl;
return;
}
Q_EMIT albumArtFetched(player, remoteUrl, localPath);
});
job->start();
}

View file

@ -0,0 +1,99 @@
/**
* SPDX-FileCopyrightText: 2023 Krut Patel <kroot.patel@gmail.com>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
#ifndef ALBUMARTCACHE_H
#define ALBUMARTCACHE_H
#include "networkpacket.h"
#include <QCache>
#include <QDir>
#include <QObject>
#include <QReadWriteLock>
#include <QSharedPointer>
#include <optional>
class MprisRemotePlugin;
/**
* Wrapper that automatically deletes the file from disk when destroyed.
*/
class LocalFile
{
public:
QUrl localPath;
explicit LocalFile(QUrl localPath)
: localPath(std::move(localPath))
{
}
~LocalFile()
{
// delete the file from disk
// TODO: Log warning if this failed
QFile::remove(localPath.toLocalFile());
}
};
class AlbumArtCache : public QObject
{
Q_OBJECT
AlbumArtCache();
~AlbumArtCache() override;
public:
struct IndexItem {
enum Status {
FETCHING,
SUCCESS,
FAILED,
};
Status fetchStatus;
QSharedPointer<LocalFile> file;
explicit IndexItem(QUrl localPath)
: fetchStatus(Status::SUCCESS)
, file(new LocalFile{std::move(localPath)})
{
}
explicit IndexItem(Status fetchStatus)
: fetchStatus(fetchStatus)
, file(nullptr)
{
// Need localPath in this case
Q_ASSERT(fetchStatus != Status::SUCCESS);
}
};
static AlbumArtCache *instance();
/**
* @brief Get the Album Art object. Called from mpris media player to be sent to dbus.
*
* @return IndexItem Current status of the art file.
*/
static IndexItem getAlbumArt(const QString &remoteUrl, MprisRemotePlugin *plugin, const QString &player);
/**
* @brief Callback for when plugin receives a packet with album art.
* Can't use slot since NetworkPacket isn't a registered type.
*/
void handleAlbumArt(const NetworkPacket &np);
// called by handleAlbumArt when the album art is fetched and stored to disk
Q_SIGNALS:
void albumArtFetched(const QString &player, const QString &remoteUrl, QSharedPointer<LocalFile> localPath);
private:
using Index = QCache<QString, IndexItem>;
QDir m_cacheDir;
Index m_cachedFiles;
QReadWriteLock m_cacheLock;
};
#endif // ALBUMARTCACHE_H

View file

@ -5,6 +5,7 @@
*/
#include "mprisremoteplayer.h"
#include "albumart_cache.h"
#include "mprisremoteplayermediaplayer2.h"
#include "mprisremoteplayermediaplayer2player.h"
#include "mprisremoteplugin.h"
@ -14,6 +15,8 @@
#include <QUuid>
#include <networkpacket.h>
#include "plugin_mprisremote_debug.h"
MprisRemotePlayer::MprisRemotePlayer(QString id, MprisRemotePlugin *plugin)
: QObject(plugin)
, id(id)
@ -33,6 +36,7 @@ MprisRemotePlayer::MprisRemotePlayer(QString id, MprisRemotePlugin *plugin)
, m_dbusConnectionName(QStringLiteral("mpris_") + QUuid::createUuid().toString(QUuid::Id128))
, m_dbusConnection(QDBusConnection::connectToBus(QDBusConnection::SessionBus, m_dbusConnectionName))
{
connect(AlbumArtCache::instance(), &AlbumArtCache::albumArtFetched, this, &MprisRemotePlayer::albumArtFetched);
// Expose this player on the newly created connection. This allows multiple mpris services in the same Qt process
new MprisRemotePlayerMediaPlayer2(this, plugin);
new MprisRemotePlayerMediaPlayer2Player(this, plugin);
@ -56,7 +60,17 @@ void MprisRemotePlayer::parseNetworkPacket(const NetworkPacket &np)
QString newTitle = np.get<QString>(QStringLiteral("title"), m_title);
QString newArtist = np.get<QString>(QStringLiteral("artist"), m_artist);
QString newAlbum = np.get<QString>(QStringLiteral("album"), m_album);
QString newAlbumArtUrl = np.get<QString>(QStringLiteral("albumArtUrl"), QStringLiteral(""));
int newLength = np.get<int>(QStringLiteral("length"), m_length);
if (newAlbumArtUrl != m_albumArtUrl) {
// album art changed
m_albumArtUrl = newAlbumArtUrl;
auto url = AlbumArtCache::getAlbumArt(newAlbumArtUrl, (MprisRemotePlugin *)(parent()), identity());
// if empty, will be populated once album art has been fetched
if (url.fetchStatus != AlbumArtCache::IndexItem::FETCHING) {
setLocalAlbumArtUrl(url.file);
}
}
// Check if they changed
if (newTitle != m_title || newArtist != m_artist || newAlbum != m_album || newLength != m_length) {
@ -126,6 +140,13 @@ void MprisRemotePlayer::setPosition(long position)
m_lastPositionTime = QDateTime::currentMSecsSinceEpoch();
}
void MprisRemotePlayer::setLocalAlbumArtUrl(const QSharedPointer<LocalFile> &url)
{
m_localAlbumArtUrl = url;
qCDebug(KDECONNECT_PLUGIN_MPRISREMOTE) << "Setting local url" << (url ? url->localPath.toDisplayString() : QStringLiteral("(null)"));
Q_EMIT trackInfoChanged();
}
int MprisRemotePlayer::volume() const
{
return m_volume;
@ -191,4 +212,32 @@ QDBusConnection &MprisRemotePlayer::dbus()
return m_dbusConnection;
}
QString MprisRemotePlayer::albumArtUrl() const
{
return m_albumArtUrl;
}
QUrl MprisRemotePlayer::localAlbumArtUrl() const
{
return m_localAlbumArtUrl ? m_localAlbumArtUrl->localPath : QUrl{};
}
void MprisRemotePlayer::albumArtFetched(const QString &player, const QString &remoteUrl, const QSharedPointer<LocalFile> &localPath)
{
if (identity() != player) {
// doesn't concern us
return;
}
if (albumArtUrl() != remoteUrl) {
// doesn't concern us
return;
}
Q_ASSERT(localPath);
if (localAlbumArtUrl() == localPath->localPath) {
// already set
return;
}
setLocalAlbumArtUrl(localPath);
}
#include "moc_mprisremoteplayer.cpp"

View file

@ -5,8 +5,10 @@
*/
#pragma once
#include "albumart_cache.h"
#include <QDBusConnection>
#include <QString>
#include <QUrl>
class NetworkPacket;
class MprisRemotePlugin;
@ -22,12 +24,15 @@ public:
void parseNetworkPacket(const NetworkPacket &np);
long position() const;
void setPosition(long position);
void setLocalAlbumArtUrl(const QSharedPointer<LocalFile> &url);
int volume() const;
long length() const;
bool playing() const;
QString title() const;
QString artist() const;
QString album() const;
QString albumArtUrl() const;
QUrl localAlbumArtUrl() const;
QString identity() const;
bool canSeek() const;
@ -45,6 +50,9 @@ Q_SIGNALS:
void volumeChanged();
void playingChanged();
private:
void albumArtFetched(const QString &player, const QString &remoteUrl, const QSharedPointer<LocalFile> &localPath);
private:
QString id;
bool m_playing;
@ -59,6 +67,11 @@ private:
QString m_title;
QString m_artist;
QString m_album;
QString m_albumArtUrl;
// hold a strong reference so that the file doesn't get deleted while in use
QSharedPointer<LocalFile> m_localAlbumArtUrl;
private:
bool m_canSeek;
// Use an unique connection for every player, otherwise we can't distinguish which mpris player is being controlled

View file

@ -58,6 +58,9 @@ QVariantMap MprisRemotePlayerMediaPlayer2Player::Metadata() const
if (!m_parent->album().isEmpty()) {
metadata[QStringLiteral("xesam:album")] = m_parent->album();
}
if (!m_parent->localAlbumArtUrl().isEmpty()) {
metadata[QStringLiteral("mpris:artUrl")] = m_parent->localAlbumArtUrl().toString();
}
return metadata;
}

View file

@ -9,9 +9,12 @@
#include <KPluginFactory>
#include <QDebug>
#include <QStandardPaths>
#include <core/device.h>
#include <filetransferjob.h>
#include "albumart_cache.h"
#include "plugin_mprisremote_debug.h"
K_PLUGIN_CLASS_WITH_JSON(MprisRemotePlugin, "kdeconnect_mprisremote.json")
@ -21,6 +24,11 @@ void MprisRemotePlugin::receivePacket(const NetworkPacket &np)
if (np.type() != PACKET_TYPE_MPRIS)
return;
if (np.get<bool>(QStringLiteral("transferringAlbumArt"), false)) {
AlbumArtCache::instance()->handleAlbumArt(np);
return;
}
if (np.has(QStringLiteral("player"))) {
const QString player = np.get<QString>(QStringLiteral("player"));
if (!m_players.contains(player)) {
@ -83,6 +91,13 @@ void MprisRemotePlugin::requestPlayerList()
sendPacket(np);
}
void MprisRemotePlugin::requestAlbumArt(const QString &player, const QString &album_art_url)
{
NetworkPacket np(PACKET_TYPE_MPRIS_REQUEST, {{QStringLiteral("player"), player}, {QStringLiteral("albumArtUrl"), album_art_url}});
qInfo(KDECONNECT_PLUGIN_MPRISREMOTE) << "Requesting album art " << np.serialize();
sendPacket(np);
}
void MprisRemotePlugin::sendAction(const QString &action)
{
NetworkPacket np(PACKET_TYPE_MPRIS_REQUEST, {{QStringLiteral("player"), m_currentPlayer}, {QStringLiteral("action"), action}});

View file

@ -54,6 +54,8 @@ public:
Q_SCRIPTABLE void seek(int offset) const;
Q_SCRIPTABLE void requestPlayerList();
Q_SCRIPTABLE void sendAction(const QString &action);
// we don't want this to be exposed via dbus, right?
void requestAlbumArt(const QString &player, const QString &album_art_url);
Q_SIGNALS:
Q_SCRIPTABLE void propertiesChanged();