mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2025-04-15 21:57:10 +02:00
Request song covers through MTProto.
This commit is contained in:
parent
be133fce78
commit
087074fea4
16 changed files with 145 additions and 185 deletions
|
@ -1177,8 +1177,6 @@ PRIVATE
|
|||
storage/storage_account.h
|
||||
storage/storage_cloud_blob.cpp
|
||||
storage/storage_cloud_blob.h
|
||||
storage/storage_cloud_song_cover.cpp
|
||||
storage/storage_cloud_song_cover.h
|
||||
storage/storage_domain.cpp
|
||||
storage/storage_domain.h
|
||||
storage/storage_facade.cpp
|
||||
|
|
|
@ -850,6 +850,7 @@ inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector
|
|||
|
||||
inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation;
|
||||
inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation;
|
||||
inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation;
|
||||
|
||||
upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile;
|
||||
|
||||
|
|
|
@ -37,7 +37,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
#include "history/view/media/history_view_gif.h"
|
||||
#include "window/window_session_controller.h"
|
||||
#include "storage/cache/storage_cache_database.h"
|
||||
#include "storage/storage_cloud_song_cover.h"
|
||||
#include "ui/boxes/confirm_box.h"
|
||||
#include "ui/image/image.h"
|
||||
#include "ui/text/text_utilities.h"
|
||||
|
@ -53,6 +52,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
|
||||
namespace {
|
||||
|
||||
constexpr auto kDefaultCoverThumbnailSize = 100;
|
||||
|
||||
const auto kLottieStickerDimensions = QSize(
|
||||
kStickerSideSize,
|
||||
kStickerSideSize);
|
||||
|
@ -407,13 +408,7 @@ void DocumentData::setattributes(
|
|||
songData->duration = data.vduration().v;
|
||||
songData->title = qs(data.vtitle().value_or_empty());
|
||||
songData->performer = qs(data.vperformer().value_or_empty());
|
||||
|
||||
if (!hasThumbnail()
|
||||
&& !songData->title.isEmpty()
|
||||
&& !songData->performer.isEmpty()) {
|
||||
|
||||
Storage::CloudSongCover::LoadThumbnailFromExternal(this);
|
||||
}
|
||||
refreshPossibleCoverThumbnail();
|
||||
}
|
||||
}, [&](const MTPDdocumentAttributeFilename &data) {
|
||||
setFileName(qs(data.vfile_name()));
|
||||
|
@ -556,7 +551,9 @@ bool DocumentData::isPremiumEmoji() const {
|
|||
}
|
||||
|
||||
bool DocumentData::hasThumbnail() const {
|
||||
return _thumbnail.location.valid();
|
||||
return _thumbnail.location.valid()
|
||||
&& !thumbnailFailed()
|
||||
&& !(_flags & Flag::PossibleCoverThumbnail);
|
||||
}
|
||||
|
||||
bool DocumentData::thumbnailLoading() const {
|
||||
|
@ -576,6 +573,7 @@ void DocumentData::loadThumbnail(Data::FileOrigin origin) {
|
|||
return true;
|
||||
};
|
||||
const auto done = [=](QImage result, QByteArray) {
|
||||
_flags &= ~Flag::PossibleCoverThumbnail;
|
||||
if (const auto active = activeMediaView()) {
|
||||
active->setThumbnail(std::move(result));
|
||||
}
|
||||
|
@ -1143,6 +1141,31 @@ bool DocumentData::saveFromDataChecked() {
|
|||
return true;
|
||||
}
|
||||
|
||||
void DocumentData::refreshPossibleCoverThumbnail() {
|
||||
Expects(isSong());
|
||||
|
||||
if (_thumbnail.location.valid()) {
|
||||
return;
|
||||
}
|
||||
const auto songData = song();
|
||||
if (songData->performer.isEmpty()
|
||||
|| songData->title.isEmpty()
|
||||
// Ignore cover for voice chat records.
|
||||
|| hasMimeType(qstr("audio/ogg"))) {
|
||||
return;
|
||||
}
|
||||
const auto size = kDefaultCoverThumbnailSize;
|
||||
const auto location = ImageWithLocation{
|
||||
.location = ImageLocation(
|
||||
{ AudioAlbumThumbLocation{ id } },
|
||||
size,
|
||||
size)
|
||||
};
|
||||
_flags |= Flag::PossibleCoverThumbnail;
|
||||
updateThumbnails({}, location, {}, false);
|
||||
loadThumbnail({});
|
||||
}
|
||||
|
||||
bool DocumentData::isStickerSetInstalled() const {
|
||||
Expects(sticker() != nullptr);
|
||||
|
||||
|
|
|
@ -285,6 +285,7 @@ private:
|
|||
InlineThumbnailIsPath = 0x080,
|
||||
ForceToCache = 0x100,
|
||||
PremiumSticker = 0x200,
|
||||
PossibleCoverThumbnail = 0x400,
|
||||
};
|
||||
using Flags = base::flags<Flag>;
|
||||
friend constexpr bool is_flag_type(Flag) { return true; };
|
||||
|
@ -326,6 +327,8 @@ private:
|
|||
|
||||
bool saveFromDataChecked();
|
||||
|
||||
void refreshPossibleCoverThumbnail();
|
||||
|
||||
const not_null<Data::Session*> _owner;
|
||||
|
||||
// Two types of location: from MTProto by dc+access or from web by url
|
||||
|
|
|
@ -1085,9 +1085,7 @@ rpl::producer<Ui::DownloadBarContent> MakeDownloadBarContent() {
|
|||
auto &manager = Core::App().downloadManager();
|
||||
|
||||
const auto resolveThumbnailRecursive = [=](auto &&self) -> bool {
|
||||
if (state->document
|
||||
&& (!state->document->hasThumbnail()
|
||||
|| state->document->thumbnailFailed())) {
|
||||
if (state->document && !state->document->hasThumbnail()) {
|
||||
state->media = nullptr;
|
||||
}
|
||||
if (!state->media) {
|
||||
|
|
|
@ -18,6 +18,7 @@ constexpr auto kDocumentCacheTag = 0x0000000000000100ULL;
|
|||
constexpr auto kDocumentCacheMask = 0x00000000000000FFULL;
|
||||
constexpr auto kDocumentThumbCacheTag = 0x0000000000000200ULL;
|
||||
constexpr auto kDocumentThumbCacheMask = 0x00000000000000FFULL;
|
||||
constexpr auto kAudioAlbumThumbCacheTag = 0x0000000000000300ULL;
|
||||
constexpr auto kWebDocumentCacheTag = 0x0000020000000000ULL;
|
||||
constexpr auto kUrlCacheTag = 0x0000030000000000ULL;
|
||||
constexpr auto kGeoPointCacheTag = 0x0000040000000000ULL;
|
||||
|
@ -86,6 +87,14 @@ Storage::Cache::Key GeoPointCacheKey(const GeoPointLocation &location) {
|
|||
};
|
||||
}
|
||||
|
||||
Storage::Cache::Key AudioAlbumThumbCacheKey(
|
||||
const AudioAlbumThumbLocation &location) {
|
||||
return Storage::Cache::Key{
|
||||
Data::kAudioAlbumThumbCacheTag,
|
||||
location.documentId,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace Data
|
||||
|
||||
void MessageCursor::fillFrom(not_null<const Ui::InputField*> field) {
|
||||
|
|
|
@ -49,6 +49,8 @@ Storage::Cache::Key DocumentThumbCacheKey(int32 dcId, uint64 id);
|
|||
Storage::Cache::Key WebDocumentCacheKey(const WebFileLocation &location);
|
||||
Storage::Cache::Key UrlCacheKey(const QString &location);
|
||||
Storage::Cache::Key GeoPointCacheKey(const GeoPointLocation &location);
|
||||
Storage::Cache::Key AudioAlbumThumbCacheKey(
|
||||
const AudioAlbumThumbLocation &location);
|
||||
|
||||
constexpr auto kImageCacheTag = uint8(0x01);
|
||||
constexpr auto kStickerCacheTag = uint8(0x02);
|
||||
|
|
|
@ -11,6 +11,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|||
#include "mtproto/mtproto_auth_key.h"
|
||||
#include "mtproto/mtproto_response.h"
|
||||
#include "main/main_session.h"
|
||||
#include "data/data_session.h"
|
||||
#include "data/data_document.h"
|
||||
#include "apiwrap.h"
|
||||
#include "base/openssl_help.h"
|
||||
|
||||
|
@ -557,6 +559,22 @@ mtpRequestId DownloadMtprotoTask::sendRequest(
|
|||
}).fail([=](const MTP::Error &error, mtpRequestId id) {
|
||||
partFailed(error, id);
|
||||
}).toDC(shiftedDcId).send();
|
||||
}, [&](const AudioAlbumThumbLocation &location) {
|
||||
using Flag = MTPDinputWebFileAudioAlbumThumbLocation::Flag;
|
||||
const auto owner = &api().session().data();
|
||||
return api().request(MTPupload_GetWebFile(
|
||||
MTP_inputWebFileAudioAlbumThumbLocation(
|
||||
MTP_flags(Flag::f_document),
|
||||
owner->document(location.documentId)->mtpInput(),
|
||||
MTPstring(),
|
||||
MTPstring()),
|
||||
MTP_int(offset),
|
||||
MTP_int(limit)
|
||||
)).done([=](const MTPupload_WebFile &result, mtpRequestId id) {
|
||||
webPartLoaded(result, id);
|
||||
}).fail([=](const MTP::Error &error, mtpRequestId id) {
|
||||
partFailed(error, id);
|
||||
}).toDC(shiftedDcId).send();
|
||||
}, [&](const StorageFileLocation &location) {
|
||||
const auto reference = location.fileReference();
|
||||
return api().request(MTPupload_GetFile(
|
||||
|
|
|
@ -127,7 +127,8 @@ public:
|
|||
std::variant<
|
||||
StorageFileLocation,
|
||||
WebFileLocation,
|
||||
GeoPointLocation> data;
|
||||
GeoPointLocation,
|
||||
AudioAlbumThumbLocation> data;
|
||||
};
|
||||
|
||||
DownloadMtprotoTask(
|
||||
|
|
|
@ -525,6 +525,15 @@ std::unique_ptr<FileLoader> CreateFileLoader(
|
|||
fromCloud,
|
||||
autoLoading,
|
||||
cacheTag);
|
||||
}, [&](const AudioAlbumThumbLocation &data) {
|
||||
result = std::make_unique<mtpFileLoader>(
|
||||
session,
|
||||
data,
|
||||
loadSize,
|
||||
fullSize,
|
||||
fromCloud,
|
||||
autoLoading,
|
||||
cacheTag);
|
||||
}, [&](const InMemoryLocation &data) {
|
||||
result = std::make_unique<FromMemoryLoader>(
|
||||
session,
|
||||
|
|
|
@ -89,6 +89,30 @@ mtpFileLoader::mtpFileLoader(
|
|||
{ location }) {
|
||||
}
|
||||
|
||||
mtpFileLoader::mtpFileLoader(
|
||||
not_null<Main::Session*> session,
|
||||
const AudioAlbumThumbLocation &location,
|
||||
int64 loadSize,
|
||||
int64 fullSize,
|
||||
LoadFromCloudSetting fromCloud,
|
||||
bool autoLoading,
|
||||
uint8 cacheTag)
|
||||
: FileLoader(
|
||||
session,
|
||||
QString(),
|
||||
loadSize,
|
||||
fullSize,
|
||||
UnknownFileLocation,
|
||||
LoadToCacheAsWell,
|
||||
fromCloud,
|
||||
autoLoading,
|
||||
cacheTag)
|
||||
, DownloadMtprotoTask(
|
||||
&session->downloader(),
|
||||
session->serverConfig().webFileDcId,
|
||||
{ location }) {
|
||||
}
|
||||
|
||||
mtpFileLoader::~mtpFileLoader() {
|
||||
if (!_finished) {
|
||||
cancel();
|
||||
|
@ -184,6 +208,8 @@ Storage::Cache::Key mtpFileLoader::cacheKey() const {
|
|||
return Data::GeoPointCacheKey(location);
|
||||
}, [&](const StorageFileLocation &location) {
|
||||
return location.cacheKey();
|
||||
}, [&](const AudioAlbumThumbLocation &location) {
|
||||
return Data::AudioAlbumThumbCacheKey(location);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,14 @@ public:
|
|||
LoadFromCloudSetting fromCloud,
|
||||
bool autoLoading,
|
||||
uint8 cacheTag);
|
||||
mtpFileLoader(
|
||||
not_null<Main::Session*> session,
|
||||
const AudioAlbumThumbLocation &location,
|
||||
int64 loadSize,
|
||||
int64 fullSize,
|
||||
LoadFromCloudSetting fromCloud,
|
||||
bool autoLoading,
|
||||
uint8 cacheTag);
|
||||
~mtpFileLoader();
|
||||
|
||||
Data::FileOrigin fileOrigin() const override;
|
||||
|
|
|
@ -1,154 +0,0 @@
|
|||
/*
|
||||
This file is part of Telegram Desktop,
|
||||
the official desktop application for the Telegram messaging service.
|
||||
|
||||
For license and copyright information please follow this link:
|
||||
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||
*/
|
||||
#include "storage/storage_cloud_song_cover.h"
|
||||
|
||||
#include "data/data_cloud_file.h"
|
||||
#include "data/data_document.h"
|
||||
#include "data/data_file_origin.h"
|
||||
#include "data/data_session.h"
|
||||
#include "main/main_session.h"
|
||||
#include "storage/file_download.h"
|
||||
|
||||
#include <QtCore/QJsonArray>
|
||||
#include <QtCore/QJsonDocument>
|
||||
#include <QtCore/QJsonObject>
|
||||
#include <QtCore/QJsonValue>
|
||||
|
||||
namespace Storage::CloudSongCover {
|
||||
namespace {
|
||||
|
||||
constexpr auto kMaxResponseSize = 1024 * 1024;
|
||||
constexpr auto kDefaultCoverSize = 100;
|
||||
|
||||
struct Responce {
|
||||
const QString artworkUrl;
|
||||
const int size;
|
||||
};
|
||||
|
||||
auto Location(const QString &url) {
|
||||
return DownloadLocation{ PlainUrlLocation{ url } };
|
||||
}
|
||||
|
||||
auto JsonUrl(not_null<SongData*> song) {
|
||||
return QString("https://itunes.apple.com/search?term=" \
|
||||
"%1 %2&entity=song&limit=4").arg(song->performer, song->title);
|
||||
}
|
||||
|
||||
// Dummy JSON responce.
|
||||
// {
|
||||
// "resultCount": 2,
|
||||
// "results": [
|
||||
// {
|
||||
// "artworkUrl100": ""
|
||||
// },
|
||||
// {
|
||||
// "artworkUrl100": ""
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
std::optional<Responce> ParseResponce(const QByteArray &response) {
|
||||
if (response.size() >= kMaxResponseSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto error = QJsonParseError{ 0, QJsonParseError::NoError };
|
||||
const auto document = QJsonDocument::fromJson(response, &error);
|
||||
|
||||
const auto log = [](const QString &message) {
|
||||
DEBUG_LOG(("Parse Artwork JSON Error: %1.").arg(message));
|
||||
};
|
||||
|
||||
if (error.error != QJsonParseError::NoError) {
|
||||
log(error.errorString());
|
||||
return std::nullopt;
|
||||
} else if (!document.isObject()) {
|
||||
log("not an object received in JSON");
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto results = document.object().value("results");
|
||||
if (!results.isArray()) {
|
||||
log("'results' field not found");
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto resultsArray = results.toArray();
|
||||
if (resultsArray.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto artworkUrl = resultsArray.first().toObject()
|
||||
.value("artworkUrl100").toString();
|
||||
if (artworkUrl.isEmpty()) {
|
||||
log("'artworkUrl100' field is empty");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return Responce{ artworkUrl, kDefaultCoverSize };
|
||||
}
|
||||
|
||||
void LoadAndApplyThumbnail(
|
||||
not_null<DocumentData*> document,
|
||||
const Responce &responce) {
|
||||
const auto size = responce.size;
|
||||
const auto imageWithLocation = ImageWithLocation{
|
||||
.location = ImageLocation(Location(responce.artworkUrl), size, size)
|
||||
};
|
||||
|
||||
document->updateThumbnails(
|
||||
InlineImageLocation(),
|
||||
imageWithLocation,
|
||||
ImageWithLocation{ .location = ImageLocation() },
|
||||
document->isPremiumSticker());
|
||||
|
||||
document->loadThumbnail(Data::FileOrigin());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void LoadThumbnailFromExternal(not_null<DocumentData*> document) {
|
||||
const auto songData = document->song();
|
||||
if (!songData
|
||||
|| songData->performer.isEmpty()
|
||||
|| songData->title.isEmpty()
|
||||
// Ignore cover for voice chat records.
|
||||
|| document->hasMimeType(qstr("audio/ogg"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto &size = kDefaultCoverSize;
|
||||
const auto jsonLocation = ImageWithLocation{
|
||||
.location = ImageLocation(Location(JsonUrl(songData)), size, size)
|
||||
};
|
||||
|
||||
const auto jsonCloudFile = std::make_shared<Data::CloudFile>();
|
||||
Data::UpdateCloudFile(
|
||||
*jsonCloudFile,
|
||||
jsonLocation,
|
||||
document->owner().cache(),
|
||||
0, // Cache tag.
|
||||
nullptr,
|
||||
nullptr);
|
||||
|
||||
auto done = [=](const QByteArray &result) {
|
||||
if (!jsonCloudFile) {
|
||||
return;
|
||||
}
|
||||
if (const auto responce = ParseResponce(result)) {
|
||||
LoadAndApplyThumbnail(document, *responce);
|
||||
}
|
||||
};
|
||||
Data::LoadCloudFile(
|
||||
&document->session(),
|
||||
*jsonCloudFile,
|
||||
Data::FileOrigin(),
|
||||
LoadFromCloudOrLocal,
|
||||
true,
|
||||
0,
|
||||
[] { return true; },
|
||||
std::move(done));
|
||||
}
|
||||
|
||||
} // namespace Storage::CloudSongCover
|
|
@ -1,16 +0,0 @@
|
|||
/*
|
||||
This file is part of Telegram Desktop,
|
||||
the official desktop application for the Telegram messaging service.
|
||||
|
||||
For license and copyright information please follow this link:
|
||||
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
class DocumentData;
|
||||
|
||||
namespace Storage::CloudSongCover {
|
||||
|
||||
void LoadThumbnailFromExternal(not_null<DocumentData*> document);
|
||||
|
||||
} // namespace Storage::CloudSongCover
|
|
@ -34,6 +34,7 @@ enum class NonStorageLocationType : quint8 {
|
|||
Geo,
|
||||
Url,
|
||||
Memory,
|
||||
AudioAlbumThumb,
|
||||
};
|
||||
|
||||
MTPInputPeer GenerateInputPeer(
|
||||
|
@ -710,6 +711,11 @@ InMemoryKey inMemoryKey(const PlainUrlLocation &location) {
|
|||
return result;
|
||||
}
|
||||
|
||||
InMemoryKey inMemoryKey(const AudioAlbumThumbLocation &location) {
|
||||
const auto key = Data::AudioAlbumThumbCacheKey(location);
|
||||
return { key.high, key.low };
|
||||
}
|
||||
|
||||
InMemoryKey inMemoryKey(const InMemoryLocation &location) {
|
||||
auto result = InMemoryKey();
|
||||
const auto &data = location.bytes;
|
||||
|
@ -808,6 +814,10 @@ QByteArray DownloadLocation::serialize() const {
|
|||
<< qint32(data.height)
|
||||
<< qint32(data.zoom)
|
||||
<< qint32(data.scale);
|
||||
}, [&](const AudioAlbumThumbLocation &data) {
|
||||
stream
|
||||
<< quint8(NonStorageLocationType::AudioAlbumThumb)
|
||||
<< quint64(data.documentId);
|
||||
}, [&](const PlainUrlLocation &data) {
|
||||
stream << quint8(NonStorageLocationType::Url) << data.url.toUtf8();
|
||||
}, [&](const InMemoryLocation &data) {
|
||||
|
@ -830,6 +840,8 @@ int DownloadLocation::serializeSize() const {
|
|||
result += 2 * sizeof(qreal) + sizeof(quint64) + 4 * sizeof(qint32);
|
||||
}, [&](const PlainUrlLocation &data) {
|
||||
result += Serialize::bytearraySize(data.url.toUtf8());
|
||||
}, [&](const AudioAlbumThumbLocation &data) {
|
||||
result += sizeof(quint64);
|
||||
}, [&](const InMemoryLocation &data) {
|
||||
result += Serialize::bytearraySize(data.bytes);
|
||||
});
|
||||
|
@ -884,6 +896,15 @@ std::optional<DownloadLocation> DownloadLocation::FromSerialized(
|
|||
: std::nullopt;
|
||||
} break;
|
||||
|
||||
case NonStorageLocationType::AudioAlbumThumb: {
|
||||
quint64 id = 0;
|
||||
stream >> id;
|
||||
return (stream.status() == QDataStream::Ok)
|
||||
? std::make_optional(DownloadLocation{
|
||||
AudioAlbumThumbLocation{ id } })
|
||||
: std::nullopt;
|
||||
} break;
|
||||
|
||||
case NonStorageLocationType::Url: {
|
||||
QByteArray utf;
|
||||
stream >> utf;
|
||||
|
@ -933,6 +954,8 @@ Storage::Cache::Key DownloadLocation::cacheKey() const {
|
|||
return data.url.isEmpty()
|
||||
? Storage::Cache::Key()
|
||||
: Data::UrlCacheKey(data.url);
|
||||
}, [](const AudioAlbumThumbLocation &data) {
|
||||
return Data::AudioAlbumThumbCacheKey(data);
|
||||
}, [](const InMemoryLocation &data) {
|
||||
return Storage::Cache::Key();
|
||||
});
|
||||
|
@ -953,6 +976,8 @@ bool DownloadLocation::valid() const {
|
|||
return !data.isNull();
|
||||
}, [](const PlainUrlLocation &data) {
|
||||
return !data.url.isEmpty();
|
||||
}, [](const AudioAlbumThumbLocation &data) {
|
||||
return data.documentId != 0;
|
||||
}, [](const InMemoryLocation &data) {
|
||||
return !data.bytes.isEmpty();
|
||||
});
|
||||
|
|
|
@ -408,6 +408,14 @@ inline bool operator>=(
|
|||
return !(a < b);
|
||||
}
|
||||
|
||||
struct AudioAlbumThumbLocation {
|
||||
uint64 documentId = 0;
|
||||
|
||||
friend inline auto operator<=>(
|
||||
AudioAlbumThumbLocation,
|
||||
AudioAlbumThumbLocation) = default;
|
||||
};
|
||||
|
||||
struct InMemoryLocation {
|
||||
QByteArray bytes;
|
||||
|
||||
|
@ -454,6 +462,7 @@ public:
|
|||
WebFileLocation,
|
||||
GeoPointLocation,
|
||||
PlainUrlLocation,
|
||||
AudioAlbumThumbLocation,
|
||||
InMemoryLocation> data;
|
||||
|
||||
[[nodiscard]] QByteArray serialize() const;
|
||||
|
|
Loading…
Add table
Reference in a new issue