2129 lines
76 KiB
C++
2129 lines
76 KiB
C++
#include "mediaservice.h"
|
|
#include "../../api/apiclient.h"
|
|
#include "../../config/config_keys.h"
|
|
#include "../../config/configstore.h"
|
|
#include "../manager/servermanager.h"
|
|
#include <QBuffer>
|
|
#include <QCoreApplication>
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QHash>
|
|
#include <QImage>
|
|
#include <QImageReader>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QNetworkAccessManager>
|
|
#include <QNetworkDiskCache>
|
|
#include <QNetworkReply>
|
|
#include <QPointer>
|
|
#include <QSettings>
|
|
#include <QSet>
|
|
#include <QStandardPaths>
|
|
#include <QUrl>
|
|
#include <QUrlQuery>
|
|
#include <QUuid>
|
|
#include <QtConcurrent/QtConcurrent>
|
|
#include <algorithm>
|
|
#include <qcorofuture.h>
|
|
#include <qcoronetwork.h>
|
|
#include <stdexcept>
|
|
#include <utility>
|
|
|
|
namespace
|
|
{
|
|
template <typename T> QList<T> parseJsonArray(const QJsonArray &array)
|
|
{
|
|
QList<T> list;
|
|
for (const auto &val : array)
|
|
{
|
|
list.append(T::fromJson(val.toObject()));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
MediaQueryPage parseMediaQueryPage(const QJsonObject& response, int startIndex,
|
|
int limit)
|
|
{
|
|
MediaQueryPage page;
|
|
page.items = parseJsonArray<MediaItem>(response.value("Items").toArray());
|
|
page.startIndex = qMax(0, startIndex);
|
|
page.limit = limit;
|
|
page.hasTotalRecordCount =
|
|
response.contains(QStringLiteral("TotalRecordCount"));
|
|
page.totalRecordCount =
|
|
response.value(QStringLiteral("TotalRecordCount"))
|
|
.toInt(page.startIndex + page.items.size());
|
|
return page;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constexpr int kRecommendCacheFormatVersion = 7;
|
|
constexpr int kDefaultRecommendFetchLimit = 1000;
|
|
|
|
QString appendMediaCardTooltipFields(QString fieldsCsv)
|
|
{
|
|
QStringList fields =
|
|
fieldsCsv.split(QLatin1Char(','), Qt::SkipEmptyParts);
|
|
|
|
for (QString& field : fields) {
|
|
field = field.trimmed();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const QStringList tooltipFields = {
|
|
QStringLiteral("PremiereDate"),
|
|
QStringLiteral("RunTimeTicks"),
|
|
QStringLiteral("Overview"),
|
|
QStringLiteral("Genres"),
|
|
QStringLiteral("OfficialRating"),
|
|
QStringLiteral("Taglines"),
|
|
QStringLiteral("MediaSources"),
|
|
QStringLiteral("MediaStreams"),
|
|
QStringLiteral("Tags"),
|
|
QStringLiteral("Studios"),
|
|
QStringLiteral("ExternalUrls"),
|
|
};
|
|
|
|
for (const QString& field : tooltipFields) {
|
|
if (!fields.contains(field, Qt::CaseInsensitive)) {
|
|
fields.append(field);
|
|
}
|
|
}
|
|
|
|
return fields.join(QLatin1Char(','));
|
|
}
|
|
|
|
struct PlayedItemEntry
|
|
{
|
|
MediaItem item;
|
|
QDateTime playedAt;
|
|
int sequence = 0;
|
|
};
|
|
|
|
QDateTime parseServerDateTime(const QString& rawValue)
|
|
{
|
|
const QString trimmed = rawValue.trimmed();
|
|
if (trimmed.isEmpty()) {
|
|
return {};
|
|
}
|
|
|
|
QDateTime parsed = QDateTime::fromString(trimmed, Qt::ISODateWithMs);
|
|
if (!parsed.isValid()) {
|
|
parsed = QDateTime::fromString(trimmed, Qt::ISODate);
|
|
}
|
|
return parsed.isValid() ? parsed.toUTC() : QDateTime();
|
|
}
|
|
|
|
QDateTime mediaDateCreated(const MediaItem& item)
|
|
{
|
|
const QDateTime rawDate = parseServerDateTime(item.dateCreatedRaw);
|
|
if (rawDate.isValid()) {
|
|
return rawDate;
|
|
}
|
|
return parseServerDateTime(item.dateCreated);
|
|
}
|
|
|
|
QDateTime mediaPlayedAt(const MediaItem& item)
|
|
{
|
|
return parseServerDateTime(item.userData.lastPlayedDate);
|
|
}
|
|
|
|
QString mediaSortTitle(const MediaItem& item)
|
|
{
|
|
const QString sortTitle = item.sortName.trimmed();
|
|
return sortTitle.isEmpty() ? item.name.trimmed() : sortTitle;
|
|
}
|
|
|
|
int compareDateTime(const QDateTime& lhs, const QDateTime& rhs)
|
|
{
|
|
const qint64 lhsValue = lhs.isValid() ? lhs.toMSecsSinceEpoch() : 0;
|
|
const qint64 rhsValue = rhs.isValid() ? rhs.toMSecsSinceEpoch() : 0;
|
|
if (lhsValue == rhsValue) {
|
|
return 0;
|
|
}
|
|
return lhsValue < rhsValue ? -1 : 1;
|
|
}
|
|
|
|
int comparePlayedEntries(const PlayedItemEntry& lhs,
|
|
const PlayedItemEntry& rhs,
|
|
const QString& sortBy)
|
|
{
|
|
if (sortBy.compare(QStringLiteral("DateCreated"), Qt::CaseInsensitive) == 0) {
|
|
return compareDateTime(mediaDateCreated(lhs.item),
|
|
mediaDateCreated(rhs.item));
|
|
}
|
|
if (sortBy.compare(QStringLiteral("Runtime"), Qt::CaseInsensitive) == 0) {
|
|
if (lhs.item.runTimeTicks == rhs.item.runTimeTicks) {
|
|
return 0;
|
|
}
|
|
return lhs.item.runTimeTicks < rhs.item.runTimeTicks ? -1 : 1;
|
|
}
|
|
if (sortBy.compare(QStringLiteral("PremiereDate"), Qt::CaseInsensitive) == 0) {
|
|
const int result =
|
|
QString::compare(lhs.item.premiereDate, rhs.item.premiereDate,
|
|
Qt::CaseInsensitive);
|
|
return result == 0 ? 0 : (result < 0 ? -1 : 1);
|
|
}
|
|
if (sortBy.compare(QStringLiteral("SortName"), Qt::CaseInsensitive) == 0 ||
|
|
sortBy.compare(QStringLiteral("Title"), Qt::CaseInsensitive) == 0) {
|
|
const int result =
|
|
QString::localeAwareCompare(mediaSortTitle(lhs.item),
|
|
mediaSortTitle(rhs.item));
|
|
return result == 0 ? 0 : (result < 0 ? -1 : 1);
|
|
}
|
|
|
|
return compareDateTime(lhs.playedAt, rhs.playedAt);
|
|
}
|
|
|
|
QString mediaPageFingerprint(const QList<MediaItem>& items)
|
|
{
|
|
QString fingerprint;
|
|
for (const MediaItem& item : items) {
|
|
if (item.id.isEmpty()) {
|
|
continue;
|
|
}
|
|
fingerprint += item.id;
|
|
fingerprint += QLatin1Char('|');
|
|
}
|
|
return fingerprint;
|
|
}
|
|
|
|
QString findUserViewIdByCollectionType(const QList<MediaItem> &views,
|
|
QString collectionType)
|
|
{
|
|
collectionType = collectionType.trimmed();
|
|
if (collectionType.isEmpty())
|
|
{
|
|
return {};
|
|
}
|
|
|
|
for (const MediaItem &view : views)
|
|
{
|
|
if (view.collectionType.compare(collectionType, Qt::CaseInsensitive) == 0
|
|
&& !view.id.isEmpty())
|
|
{
|
|
return view.id;
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
int effectivePort(const QUrl& url)
|
|
{
|
|
if (url.port() > 0) {
|
|
return url.port();
|
|
}
|
|
|
|
if (url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0) {
|
|
return 443;
|
|
}
|
|
if (url.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) == 0) {
|
|
return 80;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool isSameOrigin(const QUrl& left, const QUrl& right)
|
|
{
|
|
return left.scheme().compare(right.scheme(), Qt::CaseInsensitive) == 0 &&
|
|
left.host().compare(right.host(), Qt::CaseInsensitive) == 0 &&
|
|
effectivePort(left) == effectivePort(right);
|
|
}
|
|
|
|
NetworkRequestOptions buildImageRequestOptions(const ServerProfile& profile,
|
|
const QUrl& requestUrl)
|
|
{
|
|
NetworkRequestOptions options;
|
|
if (!profile.isValid() || !requestUrl.isValid()) {
|
|
return options;
|
|
}
|
|
|
|
const QUrl profileUrl(profile.url);
|
|
if (profile.ignoreSslVerification &&
|
|
isSameOrigin(requestUrl, profileUrl)) {
|
|
options.ignoreSslErrors = true;
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
struct ResolvedImageRequest {
|
|
QUrl url;
|
|
|
|
bool isValid() const
|
|
{
|
|
return url.isValid() && !url.scheme().trimmed().isEmpty();
|
|
}
|
|
};
|
|
|
|
QString sanitizeMimeType(QString mimeType)
|
|
{
|
|
const int separatorIndex = mimeType.indexOf(QLatin1Char(';'));
|
|
if (separatorIndex >= 0) {
|
|
mimeType = mimeType.left(separatorIndex);
|
|
}
|
|
|
|
return mimeType.trimmed().toLower();
|
|
}
|
|
|
|
QString detectImageMimeType(const QByteArray& data)
|
|
{
|
|
if (data.isEmpty()) {
|
|
return {};
|
|
}
|
|
|
|
QBuffer buffer;
|
|
buffer.setData(data);
|
|
if (!buffer.open(QIODevice::ReadOnly)) {
|
|
return {};
|
|
}
|
|
|
|
QImageReader reader(&buffer);
|
|
const QString format = QString::fromLatin1(reader.format()).trimmed().toLower();
|
|
if (format.isEmpty()) {
|
|
return {};
|
|
}
|
|
|
|
return QStringLiteral("image/%1").arg(format);
|
|
}
|
|
|
|
QString buildItemImagePath(QString itemId, QString imageType, int imageIndex)
|
|
{
|
|
itemId = itemId.trimmed();
|
|
imageType = imageType.trimmed();
|
|
|
|
QString path = QStringLiteral("/Items/%1/Images/%2").arg(itemId, imageType);
|
|
if (imageIndex >= 0) {
|
|
path += QStringLiteral("/%1").arg(imageIndex);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
QString buildImageCacheKey(QString itemId, QString imageType, int imageIndex)
|
|
{
|
|
return QStringLiteral("%1|%2|%3")
|
|
.arg(itemId.trimmed(), imageType.trimmed())
|
|
.arg(imageIndex);
|
|
}
|
|
|
|
ResolvedImageRequest resolveImageRequest(const ServerManager* serverManager,
|
|
QString imageUrl)
|
|
{
|
|
ResolvedImageRequest result;
|
|
|
|
imageUrl = imageUrl.trimmed();
|
|
if (imageUrl.isEmpty()) {
|
|
return result;
|
|
}
|
|
|
|
const ServerProfile profile =
|
|
serverManager ? serverManager->activeProfile() : ServerProfile{};
|
|
QUrl resolvedUrl(imageUrl);
|
|
|
|
if (resolvedUrl.isRelative() && profile.isValid()) {
|
|
QUrl baseUrl(profile.url);
|
|
if (baseUrl.isValid() && baseUrl.path().isEmpty()) {
|
|
baseUrl.setPath(QStringLiteral("/"));
|
|
}
|
|
resolvedUrl = baseUrl.resolved(QUrl(imageUrl));
|
|
}
|
|
|
|
if (!resolvedUrl.isValid() || resolvedUrl.scheme().trimmed().isEmpty()) {
|
|
return result;
|
|
}
|
|
|
|
if (profile.isValid() && !profile.accessToken.isEmpty() &&
|
|
isSameOrigin(resolvedUrl, QUrl(profile.url))) {
|
|
QUrlQuery query(resolvedUrl);
|
|
if (!query.hasQueryItem(QStringLiteral("api_key"))) {
|
|
query.addQueryItem(QStringLiteral("api_key"), profile.accessToken);
|
|
resolvedUrl.setQuery(query);
|
|
}
|
|
}
|
|
|
|
result.url = resolvedUrl;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
MediaService::MediaService(ServerManager *serverManager, QObject *parent)
|
|
: QObject(parent), m_serverManager(serverManager)
|
|
{
|
|
m_imageManager = new QNetworkAccessManager(this);
|
|
auto *diskCache = new QNetworkDiskCache(this);
|
|
QString cachePath = QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/qEmby_ImageCache";
|
|
QDir().mkpath(cachePath);
|
|
diskCache->setCacheDirectory(cachePath);
|
|
diskCache->setMaximumCacheSize(500 * 1024 * 1024);
|
|
m_imageManager->setCache(diskCache);
|
|
}
|
|
|
|
void MediaService::ensureValidProfile() const
|
|
{
|
|
if (!m_serverManager || !m_serverManager->activeClient() || !m_serverManager->activeProfile().isValid())
|
|
{
|
|
throw std::runtime_error(tr("无效的服务器配置或用户未登录").toStdString());
|
|
}
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getUserViews(bool includeHidden)
|
|
{
|
|
ensureValidProfile();
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
|
|
if (m_userViewsCache.isValid(profile.id, profile.userId, includeHidden))
|
|
{
|
|
qDebug() << "[MediaService] getUserViews cache hit"
|
|
<< "| includeHidden=" << includeHidden
|
|
<< "| count=" << m_userViewsCache.views.size();
|
|
co_return m_userViewsCache.views;
|
|
}
|
|
|
|
|
|
QString path = QString("/Users/%1/Views").arg(profile.userId);
|
|
if (includeHidden)
|
|
{
|
|
path += QStringLiteral("?IncludeHidden=true");
|
|
}
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
const QList<MediaItem> views = parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
updateUserViewsCache(views, profile.id, profile.userId, includeHidden);
|
|
|
|
qDebug() << "[MediaService] getUserViews fetched"
|
|
<< "| includeHidden=" << includeHidden
|
|
<< "| count=" << views.size()
|
|
<< "| playlistViewId="
|
|
<< findUserViewIdByCollectionType(views, QStringLiteral("playlists"));
|
|
co_return views;
|
|
}
|
|
|
|
void MediaService::clearUserViewsCache()
|
|
{
|
|
m_userViewsCache.clear();
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getLibraryItems(const QString &parentId, const QString &sortBy,
|
|
const QString &sortOrder, const QString &filters,
|
|
const QString &includeItemTypes, int startIndex, int limit,
|
|
bool recursive, bool includeChildCount)
|
|
{
|
|
const MediaQueryPage page =
|
|
co_await getLibraryItemsPage(parentId, sortBy, sortOrder, filters,
|
|
includeItemTypes, startIndex, limit,
|
|
recursive, includeChildCount);
|
|
co_return page.items;
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getLibraryItemsPage(const QString &parentId, const QString &sortBy,
|
|
const QString &sortOrder, const QString &filters,
|
|
const QString &includeItemTypes, int startIndex, int limit,
|
|
bool recursive, bool includeChildCount)
|
|
{
|
|
ensureValidProfile();
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
|
|
QStringList fields = {QStringLiteral("BasicSyncInfo"), QStringLiteral("CanDelete"),
|
|
QStringLiteral("CanDownload"), QStringLiteral("PrimaryImageAspectRatio"),
|
|
QStringLiteral("ProductionYear"), QStringLiteral("Status"),
|
|
QStringLiteral("EndDate"), QStringLiteral("RecursiveItemCount"),
|
|
QStringLiteral("MediaType"),
|
|
QStringLiteral("DateCreated")};
|
|
if (includeChildCount)
|
|
{
|
|
fields.append(QStringLiteral("ChildCount"));
|
|
}
|
|
|
|
const QString fieldQuery =
|
|
appendMediaCardTooltipFields(fields.join(QLatin1Char(',')));
|
|
|
|
QString path = QString("/Users/%1/Items?ParentId=%2"
|
|
"&Fields=%3"
|
|
"&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1")
|
|
.arg(profile.userId, parentId, fieldQuery);
|
|
|
|
if (recursive)
|
|
path += "&Recursive=true";
|
|
if (startIndex > 0)
|
|
path += QString("&StartIndex=%1").arg(startIndex);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!includeItemTypes.isEmpty())
|
|
path += QString("&IncludeItemTypes=%1").arg(includeItemTypes);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
if (!filters.isEmpty())
|
|
path += QString("&Filters=%1").arg(filters);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
const MediaQueryPage page = parseMediaQueryPage(response, startIndex, limit);
|
|
qDebug() << "[MediaService] getLibraryItemsPage"
|
|
<< "| parentId=" << parentId
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << limit
|
|
<< "| recursive=" << recursive
|
|
<< "| includeTypes=" << includeItemTypes
|
|
<< "| filters=" << filters
|
|
<< "| returned=" << page.items.size()
|
|
<< "| total=" << page.totalRecordCount;
|
|
co_return page;
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::searchMedia(const QString &searchTerm, const QString &includeItemTypes,
|
|
const QString &sortBy, const QString &sortOrder, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?SearchTerm=%2&Recursive=true&Fields="
|
|
"%3")
|
|
.arg(profile.userId, QUrl::toPercentEncoding(searchTerm),
|
|
fieldQuery);
|
|
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!includeItemTypes.isEmpty())
|
|
path += QString("&IncludeItemTypes=%1").arg(includeItemTypes);
|
|
|
|
if (!sortBy.isEmpty() && sortBy.toLower() != "relevance")
|
|
{
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
}
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
|
|
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getSeasons(const QString &seriesId)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Shows/%1/"
|
|
"Seasons?UserId=%2&Fields=%3&"
|
|
"EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1")
|
|
.arg(seriesId, m_serverManager->activeProfile().userId,
|
|
fieldQuery);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
|
|
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getEpisodes(const QString &seriesId, const QString &seasonId,
|
|
const QString &sortBy, const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,Overview,CanDownload"));
|
|
QString path = QString("/Shows/%1/"
|
|
"Episodes?SeasonId=%2&UserId=%3&Fields=%4&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1")
|
|
.arg(seriesId, seasonId,
|
|
m_serverManager->activeProfile().userId, fieldQuery);
|
|
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
|
|
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getNextUp(const QString &seriesId)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,Overview,CanDownload"));
|
|
QString path = QString("/Shows/"
|
|
"NextUp?UserId=%1&Fields=%2&"
|
|
"EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
|
|
if (!seriesId.isEmpty())
|
|
{
|
|
path += QString("&SeriesId=%1").arg(seriesId);
|
|
}
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<QPixmap> MediaService::fetchImage(const QString &itemId,
|
|
const QString &imageType,
|
|
const QString &imageTag,
|
|
int maxWidth, int imageIndex)
|
|
{
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
if (!profile.isValid())
|
|
co_return QPixmap();
|
|
|
|
|
|
QString quality = ConfigStore::instance()->get<QString>(ConfigKeys::ImageQuality, "high");
|
|
int effectiveMaxWidth = maxWidth;
|
|
if (quality == "low")
|
|
effectiveMaxWidth = maxWidth / 2;
|
|
else if (quality == "medium")
|
|
effectiveMaxWidth = maxWidth * 3 / 4;
|
|
else if (quality == "original")
|
|
effectiveMaxWidth = 0;
|
|
|
|
const QString trimmedItemId = itemId.trimmed();
|
|
const QString trimmedImageType = imageType.trimmed();
|
|
const QString imagePath =
|
|
buildItemImagePath(trimmedItemId, trimmedImageType, imageIndex);
|
|
const QString imageCacheKey =
|
|
buildImageCacheKey(trimmedItemId, trimmedImageType, imageIndex);
|
|
const quint64 cacheVersion =
|
|
m_invalidatedImageRequestVersions.value(imageCacheKey, 0);
|
|
|
|
QString urlStr;
|
|
if (effectiveMaxWidth > 0)
|
|
{
|
|
if (!imageTag.isEmpty())
|
|
{
|
|
urlStr = QString("%1%2?tag=%3&maxWidth=%4&api_key=%5")
|
|
.arg(profile.url, imagePath, imageTag)
|
|
.arg(effectiveMaxWidth)
|
|
.arg(profile.accessToken);
|
|
}
|
|
else
|
|
{
|
|
urlStr = QString("%1%2?maxWidth=%3&api_key=%4")
|
|
.arg(profile.url, imagePath)
|
|
.arg(effectiveMaxWidth)
|
|
.arg(profile.accessToken);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
|
|
if (!imageTag.isEmpty())
|
|
{
|
|
urlStr = QString("%1%2?tag=%3&api_key=%4")
|
|
.arg(profile.url, imagePath, imageTag,
|
|
profile.accessToken);
|
|
}
|
|
else
|
|
{
|
|
urlStr = QString("%1%2?api_key=%3")
|
|
.arg(profile.url, imagePath, profile.accessToken);
|
|
}
|
|
}
|
|
|
|
if (cacheVersion > 0) {
|
|
urlStr += QStringLiteral("&qemby_image_rev=%1").arg(cacheVersion);
|
|
qDebug() << "[MediaService] fetchImage using invalidated cache version"
|
|
<< "| itemId=" << trimmedItemId
|
|
<< "| imageType=" << trimmedImageType
|
|
<< "| imageIndex=" << imageIndex
|
|
<< "| version=" << cacheVersion;
|
|
}
|
|
|
|
QNetworkRequest request((QUrl(urlStr)));
|
|
const NetworkRequestOptions requestOptions =
|
|
buildImageRequestOptions(profile, request.url());
|
|
NetworkManager::applyRequestOptions(request, requestOptions);
|
|
request.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
|
|
cacheVersion > 0 ? QNetworkRequest::PreferNetwork
|
|
: QNetworkRequest::PreferCache);
|
|
request.setAttribute(QNetworkRequest::Http2AllowedAttribute, true);
|
|
|
|
QNetworkReply *reply = m_imageManager->get(request);
|
|
NetworkManager::attachReplyHandlers(reply, requestOptions,
|
|
QStringLiteral("GET_IMAGE"));
|
|
co_await reply;
|
|
|
|
QPixmap result;
|
|
if (reply->error() == QNetworkReply::NoError)
|
|
{
|
|
QByteArray data = reply->readAll();
|
|
const int imageBytes = data.size();
|
|
|
|
|
|
|
|
|
|
auto future = QtConcurrent::run(
|
|
[data = std::move(data)]() mutable {
|
|
QImage decoded;
|
|
decoded.loadFromData(data);
|
|
return decoded;
|
|
});
|
|
QImage image = co_await future;
|
|
if (!image.isNull())
|
|
{
|
|
result = QPixmap::fromImage(std::move(image));
|
|
}
|
|
else
|
|
{
|
|
qWarning() << "[MediaService] fetchImage returned invalid image"
|
|
<< "| itemId=" << trimmedItemId
|
|
<< "| imageType=" << trimmedImageType
|
|
<< "| imageIndex=" << imageIndex
|
|
<< "| bytes=" << imageBytes;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
const int httpStatus =
|
|
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
qWarning() << "[MediaService] fetchImage failed"
|
|
<< "| itemId=" << trimmedItemId
|
|
<< "| imageType=" << trimmedImageType
|
|
<< "| imageIndex=" << imageIndex
|
|
<< "| url=" << request.url().toString(QUrl::RemoveQuery)
|
|
<< "| error="
|
|
<< NetworkManager::buildReplyErrorMessage(reply, httpStatus);
|
|
}
|
|
|
|
reply->deleteLater();
|
|
co_return result;
|
|
}
|
|
|
|
void MediaService::invalidateImageCache(QString itemId, QString imageType,
|
|
int imageIndex)
|
|
{
|
|
itemId = itemId.trimmed();
|
|
imageType = imageType.trimmed();
|
|
if (itemId.isEmpty() || imageType.isEmpty()) {
|
|
qWarning() << "[MediaService] invalidateImageCache skipped"
|
|
<< "| itemId=" << itemId
|
|
<< "| imageType=" << imageType
|
|
<< "| imageIndex=" << imageIndex;
|
|
return;
|
|
}
|
|
|
|
const quint64 nextVersion = ++m_nextImageRequestVersion;
|
|
m_invalidatedImageRequestVersions.insert(
|
|
buildImageCacheKey(itemId, imageType, imageIndex), nextVersion);
|
|
if (imageIndex >= 0) {
|
|
m_invalidatedImageRequestVersions.insert(
|
|
buildImageCacheKey(itemId, imageType, -1), nextVersion);
|
|
}
|
|
|
|
qDebug() << "[MediaService] invalidateImageCache"
|
|
<< "| itemId=" << itemId
|
|
<< "| imageType=" << imageType
|
|
<< "| imageIndex=" << imageIndex
|
|
<< "| version=" << nextVersion;
|
|
}
|
|
|
|
QCoro::Task<QPixmap> MediaService::fetchImageByUrl(QString imageUrl)
|
|
{
|
|
try {
|
|
const DownloadedImageData downloadedImage =
|
|
co_await downloadImageByUrl(std::move(imageUrl));
|
|
|
|
|
|
|
|
|
|
|
|
auto future = QtConcurrent::run(
|
|
[data = downloadedImage.data]() mutable {
|
|
QImage decoded;
|
|
decoded.loadFromData(data);
|
|
return decoded;
|
|
});
|
|
QImage image = co_await future;
|
|
if (image.isNull()) {
|
|
qWarning() << "[MediaService] fetchImageByUrl returned invalid image"
|
|
<< "| bytes=" << downloadedImage.data.size();
|
|
co_return QPixmap();
|
|
}
|
|
|
|
co_return QPixmap::fromImage(std::move(image));
|
|
} catch (const std::exception& e) {
|
|
qWarning() << "[MediaService] fetchImageByUrl failed"
|
|
<< "| error=" << e.what();
|
|
co_return QPixmap();
|
|
}
|
|
}
|
|
|
|
QCoro::Task<DownloadedImageData> MediaService::downloadImageByUrl(QString imageUrl)
|
|
{
|
|
const ResolvedImageRequest resolvedRequest =
|
|
resolveImageRequest(m_serverManager, imageUrl);
|
|
if (!resolvedRequest.isValid()) {
|
|
qWarning() << "[MediaService] downloadImageByUrl skipped invalid url"
|
|
<< "| imageUrl=" << imageUrl;
|
|
throw std::runtime_error(tr("Invalid image URL").toStdString());
|
|
}
|
|
|
|
QNetworkRequest request(resolvedRequest.url);
|
|
const ServerProfile profile =
|
|
m_serverManager ? m_serverManager->activeProfile() : ServerProfile{};
|
|
const NetworkRequestOptions requestOptions =
|
|
buildImageRequestOptions(profile, resolvedRequest.url);
|
|
NetworkManager::applyRequestOptions(request, requestOptions);
|
|
request.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
|
|
QNetworkRequest::PreferCache);
|
|
request.setAttribute(QNetworkRequest::Http2AllowedAttribute, true);
|
|
|
|
qDebug() << "[MediaService] downloadImageByUrl"
|
|
<< "| url=" << resolvedRequest.url.toString(QUrl::RemoveQuery);
|
|
|
|
QNetworkReply* reply = m_imageManager->get(request);
|
|
NetworkManager::attachReplyHandlers(reply, requestOptions,
|
|
QStringLiteral("GET_IMAGE_URL"));
|
|
co_await reply;
|
|
|
|
if (reply->error() != QNetworkReply::NoError) {
|
|
const int httpStatus =
|
|
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
const QString errorString =
|
|
NetworkManager::buildReplyErrorMessage(reply, httpStatus).trimmed();
|
|
qWarning() << "[MediaService] downloadImageByUrl failed"
|
|
<< "| url=" << resolvedRequest.url.toString(QUrl::RemoveQuery)
|
|
<< "| error=" << errorString;
|
|
reply->deleteLater();
|
|
throw std::runtime_error(errorString.toStdString());
|
|
}
|
|
|
|
DownloadedImageData result;
|
|
result.data = reply->readAll();
|
|
result.mimeType =
|
|
sanitizeMimeType(reply->header(QNetworkRequest::ContentTypeHeader).toString());
|
|
reply->deleteLater();
|
|
|
|
if (!result.mimeType.startsWith(QStringLiteral("image/"))) {
|
|
result.mimeType = detectImageMimeType(result.data);
|
|
}
|
|
|
|
if (result.data.isEmpty() ||
|
|
!result.mimeType.startsWith(QStringLiteral("image/"))) {
|
|
qWarning() << "[MediaService] downloadImageByUrl returned invalid image"
|
|
<< "| url=" << resolvedRequest.url.toString(QUrl::RemoveQuery)
|
|
<< "| bytes=" << result.data.size()
|
|
<< "| mimeType=" << result.mimeType;
|
|
throw std::runtime_error(
|
|
tr("Downloaded data is not a valid image").toStdString());
|
|
}
|
|
|
|
co_return result;
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::fetchItemPage(
|
|
QString basePath, int startIndex, int limit, QString context)
|
|
{
|
|
ensureValidProfile();
|
|
|
|
QString path = basePath;
|
|
if (startIndex > 0) {
|
|
path += QStringLiteral("&StartIndex=%1").arg(startIndex);
|
|
}
|
|
if (limit > 0) {
|
|
path += QStringLiteral("&Limit=%1").arg(limit);
|
|
}
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
const MediaQueryPage page = parseMediaQueryPage(response, startIndex, limit);
|
|
|
|
qDebug() << "[MediaService] paged fetch page"
|
|
<< "| context=" << context
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << limit
|
|
<< "| returned=" << page.items.size()
|
|
<< "| total=" << page.totalRecordCount;
|
|
co_return page;
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::fetchPagedItemList(
|
|
QString basePath, int requestedLimit, QString context, bool deduplicateItems)
|
|
{
|
|
ensureValidProfile();
|
|
|
|
QList<MediaItem> rawItems;
|
|
int requestCount = 0;
|
|
const int normalizedLimit = qMax(0, requestedLimit);
|
|
int totalRecordCount = 0;
|
|
int pageSize = 0;
|
|
|
|
if (normalizedLimit > 0) {
|
|
const MediaQueryPage initialPage =
|
|
co_await fetchItemPage(basePath, 0, normalizedLimit, context);
|
|
++requestCount;
|
|
rawItems = initialPage.items;
|
|
totalRecordCount =
|
|
qMin(normalizedLimit,
|
|
qMax(initialPage.totalRecordCount, rawItems.size()));
|
|
pageSize = initialPage.items.size();
|
|
} else {
|
|
const MediaQueryPage unboundedPage =
|
|
co_await fetchItemPage(basePath, 0, 0, context);
|
|
++requestCount;
|
|
rawItems = unboundedPage.items;
|
|
totalRecordCount =
|
|
qMax(unboundedPage.totalRecordCount, rawItems.size());
|
|
|
|
qDebug() << "[MediaService] unbounded fetch complete"
|
|
<< "| context=" << context
|
|
<< "| returned=" << rawItems.size()
|
|
<< "| total=" << totalRecordCount;
|
|
}
|
|
|
|
QSet<QString> pageFingerprints;
|
|
const QString initialFingerprint = mediaPageFingerprint(rawItems);
|
|
if (!initialFingerprint.isEmpty()) {
|
|
pageFingerprints.insert(initialFingerprint);
|
|
}
|
|
|
|
if (normalizedLimit > 0 && rawItems.size() < totalRecordCount &&
|
|
pageSize > 0) {
|
|
qDebug() << "[MediaService] paged fetch server-side page cap detected"
|
|
<< "| context=" << context
|
|
<< "| pageSize=" << pageSize
|
|
<< "| loaded=" << rawItems.size()
|
|
<< "| total=" << totalRecordCount;
|
|
|
|
while (rawItems.size() < totalRecordCount) {
|
|
const int startIndex = rawItems.size();
|
|
const int remainingCount = totalRecordCount - startIndex;
|
|
const int pageLimit = qMin(pageSize, remainingCount);
|
|
if (pageLimit <= 0) {
|
|
break;
|
|
}
|
|
|
|
const MediaQueryPage page =
|
|
co_await fetchItemPage(basePath, startIndex, pageLimit, context);
|
|
++requestCount;
|
|
if (page.items.isEmpty()) {
|
|
qWarning() << "[MediaService] paged fetch returned empty page"
|
|
<< "| context=" << context
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << pageLimit
|
|
<< "| total=" << totalRecordCount;
|
|
break;
|
|
}
|
|
|
|
const QString fingerprint = mediaPageFingerprint(page.items);
|
|
if (!fingerprint.isEmpty()) {
|
|
if (pageFingerprints.contains(fingerprint)) {
|
|
qWarning()
|
|
<< "[MediaService] paged fetch repeated page detected"
|
|
<< "| context=" << context
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << pageLimit
|
|
<< "| returned=" << page.items.size();
|
|
break;
|
|
}
|
|
pageFingerprints.insert(fingerprint);
|
|
}
|
|
|
|
rawItems.append(page.items);
|
|
totalRecordCount =
|
|
normalizedLimit > 0
|
|
? qMin(normalizedLimit,
|
|
qMax(totalRecordCount, page.totalRecordCount))
|
|
: qMax(totalRecordCount, page.totalRecordCount);
|
|
}
|
|
} else if (normalizedLimit > 0 && rawItems.size() < totalRecordCount) {
|
|
qWarning() << "[MediaService] paged fetch cannot continue"
|
|
<< "| context=" << context
|
|
<< "| loaded=" << rawItems.size()
|
|
<< "| total=" << totalRecordCount
|
|
<< "| pageSize=" << pageSize;
|
|
}
|
|
|
|
QList<MediaItem> items;
|
|
if (deduplicateItems) {
|
|
QSet<QString> itemIds;
|
|
for (const MediaItem& item : std::as_const(rawItems)) {
|
|
const QString id = item.id.trimmed();
|
|
if (!id.isEmpty()) {
|
|
if (itemIds.contains(id)) {
|
|
continue;
|
|
}
|
|
itemIds.insert(id);
|
|
}
|
|
items.append(item);
|
|
}
|
|
} else {
|
|
items = std::move(rawItems);
|
|
}
|
|
|
|
if (normalizedLimit > 0 && items.size() > normalizedLimit) {
|
|
items = items.mid(0, normalizedLimit);
|
|
}
|
|
|
|
qDebug() << "[MediaService] paged fetch complete"
|
|
<< "| context=" << context
|
|
<< "| requestedLimit=" << requestedLimit
|
|
<< "| returned=" << items.size()
|
|
<< "| total=" << totalRecordCount
|
|
<< "| requests=" << requestCount;
|
|
co_return items;
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getResumeItems(int limit, const QString &sortBy, const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/Items/"
|
|
"Resume?Recursive=true&Fields=%2&MediaTypes=Video")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
co_return co_await fetchPagedItemList(
|
|
path, limit, QStringLiteral("resume"));
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getLatestItems(int limit, const QString &sortBy, const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Fields=%2&IncludeItemTypes=Movie,Series")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
co_return co_await fetchPagedItemList(
|
|
path, limit, QStringLiteral("latest"));
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getResumeItemsPage(
|
|
const QString &sortBy, const QString &sortOrder, int startIndex, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/Items/"
|
|
"Resume?Recursive=true&Fields=%2&MediaTypes=Video")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
co_return co_await fetchItemPage(
|
|
path, startIndex, limit, QStringLiteral("resume"));
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getLatestItemsPage(
|
|
const QString &sortBy, const QString &sortOrder, int startIndex, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Fields=%2&IncludeItemTypes=Movie,Series")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
co_return co_await fetchItemPage(
|
|
path, startIndex, limit, QStringLiteral("latest"));
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getPlayedItems(int limit, const QString &sortBy, const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
const QString userId = m_serverManager->activeProfile().userId;
|
|
const QString requestSortBy = sortBy;
|
|
const QString requestSortOrder = sortOrder;
|
|
const int queryLimit = limit > 0 ? qMax(limit * 2, 50) : 0;
|
|
|
|
auto fetchPlayedItems =
|
|
[this, fieldQuery, userId](QString includeItemTypes, int requestLimit,
|
|
QString requestSortBy,
|
|
QString requestSortOrder)
|
|
-> QCoro::Task<QList<MediaItem>> {
|
|
QString path =
|
|
QString("/Users/%1/"
|
|
"Items?Recursive=true&Fields=%2&IncludeItemTypes=%3&Filters=IsPlayed")
|
|
.arg(userId, fieldQuery, includeItemTypes);
|
|
if (!requestSortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(requestSortBy);
|
|
if (!requestSortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(requestSortOrder);
|
|
|
|
co_return co_await fetchPagedItemList(
|
|
path, requestLimit,
|
|
QStringLiteral("played:%1").arg(includeItemTypes));
|
|
};
|
|
|
|
QList<PlayedItemEntry> entries;
|
|
QHash<QString, int> seriesEntryIndexes;
|
|
QHash<QString, bool> seriesCompletedCache;
|
|
int sequence = 0;
|
|
|
|
const auto appendEntry = [&entries, &sequence](MediaItem item) {
|
|
PlayedItemEntry entry;
|
|
entry.playedAt = mediaPlayedAt(item);
|
|
entry.sequence = sequence++;
|
|
entry.item = std::move(item);
|
|
entries.append(std::move(entry));
|
|
};
|
|
|
|
const QList<MediaItem> movies =
|
|
co_await fetchPlayedItems(QStringLiteral("Movie"), queryLimit,
|
|
requestSortBy, requestSortOrder);
|
|
for (MediaItem movie : movies) {
|
|
appendEntry(std::move(movie));
|
|
}
|
|
|
|
const QList<MediaItem> directSeries =
|
|
co_await fetchPlayedItems(QStringLiteral("Series"), queryLimit,
|
|
requestSortBy, requestSortOrder);
|
|
for (MediaItem series : directSeries) {
|
|
const QString seriesId = series.id.trimmed();
|
|
if (seriesId.isEmpty() || seriesEntryIndexes.contains(seriesId)) {
|
|
continue;
|
|
}
|
|
seriesEntryIndexes.insert(seriesId, entries.size());
|
|
appendEntry(std::move(series));
|
|
}
|
|
|
|
const QList<MediaItem> episodes =
|
|
co_await fetchPlayedItems(QStringLiteral("Episode"), queryLimit,
|
|
requestSortBy, requestSortOrder);
|
|
for (const MediaItem& episode : episodes) {
|
|
const QString seriesId = episode.seriesId.trimmed();
|
|
if (seriesId.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
const QDateTime episodePlayedAt = mediaPlayedAt(episode);
|
|
if (seriesEntryIndexes.contains(seriesId)) {
|
|
PlayedItemEntry& entry = entries[seriesEntryIndexes.value(seriesId)];
|
|
if (!entry.playedAt.isValid() ||
|
|
compareDateTime(entry.playedAt, episodePlayedAt) < 0) {
|
|
entry.playedAt = episodePlayedAt;
|
|
entry.item.userData.lastPlayedDate =
|
|
episode.userData.lastPlayedDate;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!seriesCompletedCache.contains(seriesId)) {
|
|
try {
|
|
MediaItem series = co_await getItemDetail(seriesId);
|
|
const bool seriesCompleted = series.userData.played;
|
|
seriesCompletedCache.insert(seriesId, seriesCompleted);
|
|
if (seriesCompleted) {
|
|
series.userData.played = true;
|
|
series.userData.playbackPositionTicks = 0;
|
|
series.userData.playedPercentage = 100.0;
|
|
series.userData.lastPlayedDate = episode.userData.lastPlayedDate;
|
|
seriesEntryIndexes.insert(seriesId, entries.size());
|
|
appendEntry(std::move(series));
|
|
continue;
|
|
}
|
|
} catch (const std::exception& e) {
|
|
seriesCompletedCache.insert(seriesId, false);
|
|
qWarning() << "[MediaService] failed to resolve played series"
|
|
<< "| seriesId=" << seriesId
|
|
<< "| error=" << e.what();
|
|
}
|
|
}
|
|
}
|
|
|
|
const bool descending =
|
|
requestSortOrder.compare(QStringLiteral("Descending"),
|
|
Qt::CaseInsensitive) == 0;
|
|
std::stable_sort(entries.begin(), entries.end(),
|
|
[requestSortBy, descending](const PlayedItemEntry& lhs,
|
|
const PlayedItemEntry& rhs) {
|
|
const int result =
|
|
comparePlayedEntries(lhs, rhs, requestSortBy);
|
|
if (result == 0) {
|
|
return lhs.sequence < rhs.sequence;
|
|
}
|
|
return descending ? result > 0 : result < 0;
|
|
});
|
|
|
|
QList<MediaItem> result;
|
|
result.reserve(limit > 0 ? qMin(limit, entries.size()) : entries.size());
|
|
for (const PlayedItemEntry& entry : std::as_const(entries)) {
|
|
if (limit > 0 && result.size() >= limit) {
|
|
break;
|
|
}
|
|
result.append(entry.item);
|
|
}
|
|
|
|
qDebug() << "[MediaService] getPlayedItems"
|
|
<< "| limit=" << limit << "| queryLimit=" << queryLimit
|
|
<< "| sortBy=" << requestSortBy
|
|
<< "| sortOrder=" << requestSortOrder
|
|
<< "| movies=" << movies.size()
|
|
<< "| directSeries=" << directSeries.size()
|
|
<< "| episodes=" << episodes.size()
|
|
<< "| returned=" << result.size();
|
|
co_return result;
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getPlayedItemsPage(
|
|
const QString &sortBy, const QString &sortOrder, int startIndex, int limit)
|
|
{
|
|
const int normalizedStartIndex = qMax(0, startIndex);
|
|
const int normalizedLimit = qMax(0, limit);
|
|
const int lookAheadCount = normalizedLimit > 0 ? normalizedLimit : 1;
|
|
const int requestedCount =
|
|
normalizedLimit > 0
|
|
? normalizedStartIndex + normalizedLimit + lookAheadCount
|
|
: 0;
|
|
QList<MediaItem> items =
|
|
co_await getPlayedItems(requestedCount, sortBy, sortOrder);
|
|
|
|
MediaQueryPage page;
|
|
page.startIndex = normalizedStartIndex;
|
|
page.limit = limit;
|
|
if (normalizedLimit > 0) {
|
|
page.items = items.mid(page.startIndex, normalizedLimit);
|
|
} else {
|
|
page.items = items.mid(page.startIndex);
|
|
}
|
|
|
|
const bool hasMore =
|
|
normalizedLimit > 0 &&
|
|
items.size() > normalizedStartIndex + page.items.size();
|
|
page.totalRecordCount =
|
|
hasMore ? normalizedStartIndex + page.items.size() + lookAheadCount
|
|
: items.size();
|
|
|
|
qDebug() << "[MediaService] getPlayedItemsPage"
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << limit
|
|
<< "| fetched=" << items.size()
|
|
<< "| returned=" << page.items.size()
|
|
<< "| total=" << page.totalRecordCount;
|
|
co_return page;
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getRecommendedMoviesPage(
|
|
const QString &sortBy, const QString &sortOrder, int startIndex, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,PrimaryImageAspectRatio,CanDownload"));
|
|
QString path = QString("/Users/%1/Items?IncludeItemTypes=Movie,Series"
|
|
"&Recursive=true"
|
|
"&Fields=%2"
|
|
"&EnableImageTypes=Primary,Backdrop,Thumb"
|
|
"&ImageTypeLimit=1")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
co_return co_await fetchItemPage(
|
|
path, startIndex, limit, QStringLiteral("recommended"));
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getRecommendedMovies(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
|
|
const auto &profile = m_serverManager->activeProfile();
|
|
const QString currentServerId = profile.id;
|
|
const QString currentUserId = profile.userId;
|
|
|
|
|
|
int cacheDurationHours = ConfigStore::instance()->get<QString>(ConfigKeys::DataCacheDuration, "24").toInt();
|
|
if (cacheDurationHours <= 0)
|
|
cacheDurationHours = 24;
|
|
|
|
|
|
if (m_recommendCache.isValid(currentServerId, currentUserId,
|
|
cacheDurationHours, limit))
|
|
{
|
|
QList<MediaItem> cached = m_recommendCache.items;
|
|
if (limit > 0 && cached.size() > limit)
|
|
{
|
|
co_return cached.mid(0, limit);
|
|
}
|
|
co_return cached;
|
|
}
|
|
|
|
|
|
if (m_recommendCache.loadFromDisk(currentServerId, currentUserId,
|
|
cacheDurationHours) &&
|
|
m_recommendCache.isValid(currentServerId, currentUserId,
|
|
cacheDurationHours, limit))
|
|
{
|
|
QList<MediaItem> cached = m_recommendCache.items;
|
|
if (limit > 0 && cached.size() > limit)
|
|
{
|
|
co_return cached.mid(0, limit);
|
|
}
|
|
co_return cached;
|
|
}
|
|
|
|
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,PrimaryImageAspectRatio,CanDownload"));
|
|
QString path = QString("/Users/%1/Items?IncludeItemTypes=Movie,Series"
|
|
"&Recursive=true"
|
|
"&Fields=%2"
|
|
"&EnableImageTypes=Primary,Backdrop,Thumb"
|
|
"&ImageTypeLimit=1")
|
|
.arg(currentUserId, fieldQuery);
|
|
const int requestLimit =
|
|
limit > 0 ? qMax(limit, kDefaultRecommendFetchLimit) : 0;
|
|
path += "&SortBy=Random&SortOrder=Ascending";
|
|
|
|
QList<MediaItem> allItems = co_await fetchPagedItemList(
|
|
path, requestLimit, QStringLiteral("recommended"), true);
|
|
|
|
|
|
m_recommendCache.items = allItems;
|
|
m_recommendCache.fetchTime = QDateTime::currentDateTime();
|
|
m_recommendCache.serverId = currentServerId;
|
|
m_recommendCache.userId = currentUserId;
|
|
m_recommendCache.requestLimit = requestLimit;
|
|
m_recommendCache.saveToDisk();
|
|
|
|
if (limit > 0 && allItems.size() > limit)
|
|
{
|
|
co_return allItems.mid(0, limit);
|
|
}
|
|
co_return allItems;
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getFavoriteMovies(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Filters=IsFavorite&"
|
|
"IncludeItemTypes=Movie&Fields=%2")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getFavoriteSeries(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Filters=IsFavorite&IncludeItemTypes=Series&"
|
|
"Fields=%2")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getFavoriteCollections(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Filters=IsFavorite&IncludeItemTypes=BoxSet")
|
|
.arg(m_serverManager->activeProfile().userId);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getFavoritePlaylists(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Filters=IsFavorite&IncludeItemTypes=Playlist")
|
|
.arg(m_serverManager->activeProfile().userId);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getFavoriteFolders(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Filters=IsFavorite&IncludeItemTypes=Folder")
|
|
.arg(m_serverManager->activeProfile().userId);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getFavoritePeople(int limit, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&Filters=IsFavorite&IncludeItemTypes=Person")
|
|
.arg(m_serverManager->activeProfile().userId);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
|
|
QCoro::Task<MediaItem> MediaService::getItemDetail(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
const ServerProfile profile = m_serverManager->activeProfile();
|
|
QString path = QString("/Users/%1/Items/"
|
|
"%2?Fields=MediaStreams,MediaSources,People,Overview,Genres,"
|
|
"ProductionYear,OfficialRating,Tags,Studios,ExternalUrls,ProviderIds,CanDownload")
|
|
.arg(profile.userId, itemId);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
MediaItem item = MediaItem::fromJson(response);
|
|
updateUserViewsCache(item, profile.id, profile.userId);
|
|
co_return item;
|
|
}
|
|
|
|
QCoro::Task<bool> MediaService::toggleFavorite(const QString &itemId, bool isFavorite)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/FavoriteItems/%2").arg(m_serverManager->activeProfile().userId, itemId);
|
|
|
|
if (isFavorite)
|
|
{
|
|
co_await m_serverManager->activeClient()->post(path, QJsonObject());
|
|
}
|
|
else
|
|
{
|
|
co_await m_serverManager->activeClient()->deleteResource(path);
|
|
}
|
|
co_return isFavorite;
|
|
}
|
|
|
|
QCoro::Task<void> MediaService::markAsPlayed(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/PlayedItems/%2").arg(m_serverManager->activeProfile().userId, itemId);
|
|
co_await m_serverManager->activeClient()->post(path, QJsonObject());
|
|
}
|
|
|
|
QCoro::Task<void> MediaService::markAsUnplayed(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Users/%1/PlayedItems/%2").arg(m_serverManager->activeProfile().userId, itemId);
|
|
co_await m_serverManager->activeClient()->deleteResource(path);
|
|
}
|
|
|
|
QCoro::Task<void> MediaService::removeFromResume(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
|
|
|
|
|
|
QString path =
|
|
QString("/Users/%1/Items/%2/HideFromResume?Hide=true").arg(m_serverManager->activeProfile().userId, itemId);
|
|
|
|
co_await m_serverManager->activeClient()->post(path, QJsonObject());
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getSimilarItems(const QString &itemId, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,ProductionYear,CanDownload"));
|
|
QString path = QString("/Items/%1/"
|
|
"Similar?UserId=%2&Limit=%3&Fields=%4")
|
|
.arg(itemId, m_serverManager->activeProfile().userId)
|
|
.arg(limit)
|
|
.arg(fieldQuery);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getItemCollections(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?IncludeItemTypes=Playlist,BoxSet&Recursive="
|
|
"true&ListItemIds=%2&Fields=%3")
|
|
.arg(m_serverManager->activeProfile().userId, itemId,
|
|
fieldQuery);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getCollectionItems(const QString &collectionId)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?ParentId=%2&Fields=%3&SortBy=SortName")
|
|
.arg(m_serverManager->activeProfile().userId, collectionId,
|
|
fieldQuery);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
co_return parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
|
|
QCoro::Task<PlaybackInfo> MediaService::getPlaybackInfo(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
QString path = QString("/Items/%1/PlaybackInfo?UserId=%2").arg(itemId, profile.userId);
|
|
|
|
QJsonObject payload;
|
|
payload["UserId"] = profile.userId;
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->post(path, payload);
|
|
co_return PlaybackInfo::fromJson(response);
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getAdditionalParts(const QString &itemId)
|
|
{
|
|
ensureValidProfile();
|
|
QString path = QString("/Videos/%1/AdditionalParts?UserId=%2").arg(itemId, m_serverManager->activeProfile().userId);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
QList<MediaItem> parts;
|
|
if (response.contains("Items"))
|
|
{
|
|
parts = parseJsonArray<MediaItem>(response["Items"].toArray());
|
|
}
|
|
else if (response.contains("data") && response["data"].isArray())
|
|
{
|
|
parts = parseJsonArray<MediaItem>(response["data"].toArray());
|
|
}
|
|
co_return parts;
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getItemsByPerson(const QString &personId, const QString &sortBy,
|
|
const QString &sortOrder)
|
|
{
|
|
const MediaQueryPage page =
|
|
co_await getItemsByPersonPage(personId, sortBy, sortOrder);
|
|
co_return page.items;
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getItemsByPersonPage(const QString &personId, const QString &sortBy,
|
|
const QString &sortOrder, int startIndex, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/"
|
|
"Items?Recursive=true&PersonIds=%2&IncludeItemTypes="
|
|
"Movie,Series,Episode&Fields=%3")
|
|
.arg(m_serverManager->activeProfile().userId, personId,
|
|
fieldQuery);
|
|
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
if (startIndex > 0)
|
|
path += QString("&StartIndex=%1").arg(startIndex);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
const MediaQueryPage page = parseMediaQueryPage(response, startIndex, limit);
|
|
qDebug() << "[MediaService] getItemsByPersonPage"
|
|
<< "| personId=" << personId
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << limit
|
|
<< "| returned=" << page.items.size()
|
|
<< "| total=" << page.totalRecordCount;
|
|
co_return page;
|
|
}
|
|
|
|
QCoro::Task<QList<MediaItem>> MediaService::getItemsByFilter(const QString &genreFilter, const QString &tagFilter,
|
|
const QString &studioFilter, const QString &sortBy,
|
|
const QString &sortOrder, int limit)
|
|
{
|
|
const MediaQueryPage page =
|
|
co_await getItemsByFilterPage(genreFilter, tagFilter, studioFilter,
|
|
sortBy, sortOrder, 0, limit);
|
|
co_return page.items;
|
|
}
|
|
|
|
QCoro::Task<MediaQueryPage> MediaService::getItemsByFilterPage(const QString &genreFilter, const QString &tagFilter,
|
|
const QString &studioFilter, const QString &sortBy,
|
|
const QString &sortOrder, int startIndex, int limit)
|
|
{
|
|
ensureValidProfile();
|
|
const QString fieldQuery = appendMediaCardTooltipFields(
|
|
QStringLiteral("PrimaryImageAspectRatio,ProductionYear,RecursiveItemCount,CanDownload"));
|
|
QString path = QString("/Users/%1/Items?Recursive=true"
|
|
"&IncludeItemTypes=Movie,Series"
|
|
"&Fields=%2"
|
|
"&EnableImageTypes=Primary,Backdrop,Thumb&ImageTypeLimit=1")
|
|
.arg(m_serverManager->activeProfile().userId, fieldQuery);
|
|
|
|
if (!genreFilter.isEmpty())
|
|
path += QString("&Genres=%1").arg(QString::fromUtf8(QUrl::toPercentEncoding(genreFilter)));
|
|
if (!tagFilter.isEmpty())
|
|
path += QString("&Tags=%1").arg(QString::fromUtf8(QUrl::toPercentEncoding(tagFilter)));
|
|
if (!studioFilter.isEmpty())
|
|
path += QString("&Studios=%1").arg(QString::fromUtf8(QUrl::toPercentEncoding(studioFilter)));
|
|
|
|
if (!sortBy.isEmpty())
|
|
path += QString("&SortBy=%1").arg(sortBy);
|
|
if (!sortOrder.isEmpty())
|
|
path += QString("&SortOrder=%1").arg(sortOrder);
|
|
if (startIndex > 0)
|
|
path += QString("&StartIndex=%1").arg(startIndex);
|
|
if (limit > 0)
|
|
path += QString("&Limit=%1").arg(limit);
|
|
|
|
qDebug() << "[MediaService] getItemsByFilterPage"
|
|
<< "| genre=" << genreFilter
|
|
<< "| tag=" << tagFilter
|
|
<< "| studio=" << studioFilter
|
|
<< "| sortBy=" << sortBy
|
|
<< "| sortOrder=" << sortOrder
|
|
<< "| startIndex=" << startIndex
|
|
<< "| limit=" << limit;
|
|
|
|
QJsonObject response = co_await m_serverManager->activeClient()->get(path);
|
|
const MediaQueryPage page = parseMediaQueryPage(response, startIndex, limit);
|
|
qDebug() << "[MediaService] getItemsByFilterPage result"
|
|
<< "| returned=" << page.items.size()
|
|
<< "| total=" << page.totalRecordCount;
|
|
co_return page;
|
|
}
|
|
|
|
QString MediaService::getStreamUrl(const QString &itemId, const QString &mediaSourceId) const
|
|
{
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
if (!profile.isValid() || itemId.isEmpty() || mediaSourceId.isEmpty())
|
|
{
|
|
return QString();
|
|
}
|
|
return QString("%1/Videos/%2/stream?static=true&mediaSourceId=%3&api_key=%4")
|
|
.arg(profile.url, itemId, mediaSourceId, profile.accessToken);
|
|
}
|
|
|
|
QString MediaService::getStreamUrl(const QString &itemId, const MediaSourceInfo &sourceInfo) const
|
|
{
|
|
ServerProfile profile = m_serverManager->activeProfile();
|
|
if (!profile.isValid() || itemId.isEmpty() || sourceInfo.id.isEmpty())
|
|
{
|
|
return QString();
|
|
}
|
|
|
|
bool defaultStrmDirect = (profile.type == ServerProfile::Jellyfin);
|
|
QSettings settings("qEmby", "Player");
|
|
bool enableStrmDirect = settings.value("EnableStrmDirectPlay", defaultStrmDirect).toBool();
|
|
|
|
if (enableStrmDirect)
|
|
{
|
|
if (sourceInfo.path.startsWith("http://", Qt::CaseInsensitive) ||
|
|
sourceInfo.path.startsWith("https://", Qt::CaseInsensitive))
|
|
{
|
|
return sourceInfo.path;
|
|
}
|
|
|
|
if (!sourceInfo.directStreamUrl.isEmpty())
|
|
{
|
|
if (sourceInfo.directStreamUrl.startsWith("http://", Qt::CaseInsensitive) ||
|
|
sourceInfo.directStreamUrl.startsWith("https://", Qt::CaseInsensitive))
|
|
{
|
|
return sourceInfo.directStreamUrl;
|
|
}
|
|
else
|
|
{
|
|
return profile.url + sourceInfo.directStreamUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
return getStreamUrl(itemId, sourceInfo.id);
|
|
}
|
|
|
|
QCoro::Task<QString> MediaService::reportPlaybackStart(QString itemId, QString mediaSourceId, long long positionTicks)
|
|
{
|
|
ensureValidProfile();
|
|
QPointer<ServerManager> serverManager(m_serverManager);
|
|
if (!serverManager)
|
|
{
|
|
qWarning() << "[API Warning] Playback Start skipped: ServerManager is no "
|
|
"longer available."
|
|
<< "ItemId:" << itemId << "MediaSourceId:" << mediaSourceId;
|
|
co_return QString();
|
|
}
|
|
ServerProfile profile = serverManager->activeProfile();
|
|
QString playSessionId;
|
|
|
|
if (profile.type == ServerProfile::Jellyfin)
|
|
{
|
|
|
|
try
|
|
{
|
|
PlaybackInfo pbInfo = co_await getPlaybackInfo(itemId);
|
|
if (!serverManager)
|
|
{
|
|
qWarning() << "[API Warning] Playback Start aborted after "
|
|
"PlaybackInfo: ServerManager was destroyed."
|
|
<< "ItemId:" << itemId;
|
|
co_return QString();
|
|
}
|
|
playSessionId = pbInfo.playSessionId;
|
|
qDebug() << "[API] Jellyfin PlaybackInfo retrieved. ServerSessionId:" << playSessionId;
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
qWarning() << "[API] Jellyfin getPlaybackInfo failed, will use local UUID:" << e.what();
|
|
}
|
|
if (playSessionId.isEmpty())
|
|
{
|
|
playSessionId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
|
qWarning() << "[API] Jellyfin PlaybackInfo returned empty PlaySessionId, "
|
|
"using local UUID as fallback:"
|
|
<< playSessionId;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
|
|
playSessionId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
|
}
|
|
|
|
QJsonObject payload;
|
|
payload["ItemId"] = itemId;
|
|
payload["MediaSourceId"] = mediaSourceId;
|
|
payload["PositionTicks"] = static_cast<qint64>(positionTicks);
|
|
payload["PlayMethod"] = "DirectPlay";
|
|
payload["IsPaused"] = false;
|
|
payload["IsMuted"] = false;
|
|
payload["CanSeek"] = true;
|
|
payload["PlaySessionId"] = playSessionId;
|
|
|
|
QJsonArray mediaTypes;
|
|
mediaTypes.append("Video");
|
|
payload["QueueableMediaTypes"] = mediaTypes;
|
|
|
|
ApiClient *client = serverManager->activeClient();
|
|
if (!client)
|
|
{
|
|
qWarning() << "[API Warning] Playback Start skipped: active client is no "
|
|
"longer available."
|
|
<< "ItemId:" << itemId << "MediaSourceId:" << mediaSourceId;
|
|
co_return QString();
|
|
}
|
|
|
|
try
|
|
{
|
|
co_await client->post("/Sessions/Playing", payload);
|
|
qDebug() << "[API] Playback Started successfully."
|
|
<< "ServerType:" << (profile.type == ServerProfile::Jellyfin ? "Jellyfin" : "Emby")
|
|
<< "SessionId:" << playSessionId << "ItemId:" << itemId << "MediaSourceId:" << mediaSourceId;
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
qDebug() << "[API Error] Playback Start failed:" << e.what();
|
|
}
|
|
co_return playSessionId;
|
|
}
|
|
|
|
QCoro::Task<void> MediaService::reportPlaybackProgress(QString itemId, QString mediaSourceId, long long positionTicks,
|
|
bool isPaused, QString playSessionId)
|
|
{
|
|
if (playSessionId.isEmpty())
|
|
co_return;
|
|
ensureValidProfile();
|
|
|
|
ApiClient *client = m_serverManager->activeClient();
|
|
if (!client)
|
|
{
|
|
qWarning() << "[API Warning] Playback Progress skipped: active client is "
|
|
"no longer available."
|
|
<< "ItemId:" << itemId << "MediaSourceId:" << mediaSourceId << "SessionId:" << playSessionId;
|
|
co_return;
|
|
}
|
|
|
|
QJsonObject payload;
|
|
payload["ItemId"] = itemId;
|
|
payload["MediaSourceId"] = mediaSourceId;
|
|
payload["PositionTicks"] = static_cast<qint64>(positionTicks);
|
|
payload["IsPaused"] = isPaused;
|
|
payload["IsMuted"] = false;
|
|
payload["PlayMethod"] = "DirectPlay";
|
|
payload["CanSeek"] = true;
|
|
payload["PlaySessionId"] = playSessionId;
|
|
|
|
QJsonArray mediaTypes;
|
|
mediaTypes.append("Video");
|
|
payload["QueueableMediaTypes"] = mediaTypes;
|
|
payload["EventName"] = isPaused ? "pause" : "timeupdate";
|
|
|
|
try
|
|
{
|
|
co_await client->post("/Sessions/Playing/Progress", payload);
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
qDebug() << "[API Error] Playback Progress failed:" << e.what();
|
|
}
|
|
}
|
|
|
|
QCoro::Task<void> MediaService::reportPlaybackStopped(QString itemId, QString mediaSourceId, long long positionTicks,
|
|
QString playSessionId)
|
|
{
|
|
if (playSessionId.isEmpty())
|
|
co_return;
|
|
ensureValidProfile();
|
|
|
|
ApiClient *client = m_serverManager->activeClient();
|
|
if (!client)
|
|
{
|
|
qWarning() << "[API Warning] Playback Stopped skipped: active client is no "
|
|
"longer available."
|
|
<< "ItemId:" << itemId << "MediaSourceId:" << mediaSourceId << "SessionId:" << playSessionId;
|
|
co_return;
|
|
}
|
|
|
|
QJsonObject payload;
|
|
payload["ItemId"] = itemId;
|
|
payload["MediaSourceId"] = mediaSourceId;
|
|
payload["PositionTicks"] = static_cast<qint64>(positionTicks);
|
|
payload["PlayMethod"] = "DirectPlay";
|
|
payload["PlaySessionId"] = playSessionId;
|
|
|
|
try
|
|
{
|
|
co_await client->post("/Sessions/Playing/Stopped", payload);
|
|
qDebug() << "[API] Playback Stopped successfully. SessionId:" << playSessionId;
|
|
}
|
|
catch (const std::exception &e)
|
|
{
|
|
qDebug() << "[API Error] Playback Stop failed:" << e.what();
|
|
}
|
|
}
|
|
|
|
void MediaService::clearRecommendCache()
|
|
{
|
|
m_recommendCache.clear();
|
|
Q_EMIT recommendCacheCleared();
|
|
}
|
|
|
|
void MediaService::removeRecommendCacheItem(const QString& itemId)
|
|
{
|
|
const QString trimmedItemId = itemId.trimmed();
|
|
if (trimmedItemId.isEmpty() || !m_serverManager ||
|
|
!m_serverManager->activeProfile().isValid()) {
|
|
return;
|
|
}
|
|
|
|
const ServerProfile profile = m_serverManager->activeProfile();
|
|
int cacheDurationHours =
|
|
ConfigStore::instance()
|
|
->get<QString>(ConfigKeys::DataCacheDuration, "24")
|
|
.toInt();
|
|
if (cacheDurationHours <= 0) {
|
|
cacheDurationHours = 24;
|
|
}
|
|
|
|
bool hasUsableCache =
|
|
m_recommendCache.isValid(profile.id, profile.userId, cacheDurationHours);
|
|
if (!hasUsableCache) {
|
|
hasUsableCache =
|
|
m_recommendCache.loadFromDisk(profile.id, profile.userId,
|
|
cacheDurationHours);
|
|
}
|
|
|
|
if (!hasUsableCache) {
|
|
qDebug() << "[MediaService] Skip recommend cache item removal"
|
|
<< "| reason=no-usable-cache"
|
|
<< "| itemId=" << trimmedItemId;
|
|
return;
|
|
}
|
|
|
|
const int previousCount = m_recommendCache.items.size();
|
|
for (int i = m_recommendCache.items.size() - 1; i >= 0; --i) {
|
|
if (m_recommendCache.items.at(i).id == trimmedItemId) {
|
|
m_recommendCache.items.removeAt(i);
|
|
}
|
|
}
|
|
|
|
if (m_recommendCache.items.size() == previousCount) {
|
|
qDebug() << "[MediaService] Skip recommend cache item removal"
|
|
<< "| reason=item-not-found"
|
|
<< "| itemId=" << trimmedItemId
|
|
<< "| cachedCount=" << previousCount;
|
|
return;
|
|
}
|
|
|
|
if (m_recommendCache.items.isEmpty()) {
|
|
m_recommendCache.clear();
|
|
} else {
|
|
m_recommendCache.saveToDisk();
|
|
}
|
|
|
|
qDebug() << "[MediaService] Removed item from recommend cache"
|
|
<< "| itemId=" << trimmedItemId
|
|
<< "| previousCount=" << previousCount
|
|
<< "| currentCount=" << m_recommendCache.items.size();
|
|
}
|
|
|
|
void MediaService::updateUserViewsCache(QList<MediaItem> views, QString serverId,
|
|
QString userId, bool includesHidden)
|
|
{
|
|
m_userViewsCache.views = std::move(views);
|
|
m_userViewsCache.serverId = std::move(serverId);
|
|
m_userViewsCache.userId = std::move(userId);
|
|
m_userViewsCache.includesHidden = includesHidden;
|
|
}
|
|
|
|
void MediaService::updateUserViewsCache(MediaItem view, QString serverId,
|
|
QString userId)
|
|
{
|
|
const QString viewId = view.id.trimmed();
|
|
const QString viewType = view.type.trimmed();
|
|
const QString collectionType = view.collectionType.trimmed();
|
|
|
|
if (viewId.isEmpty() || collectionType.isEmpty())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!m_userViewsCache.isValid(serverId, userId, false)
|
|
|| m_userViewsCache.views.isEmpty())
|
|
{
|
|
qDebug() << "[MediaService] Skipping partial user view cache update"
|
|
<< "| reason=base-cache-missing"
|
|
<< "| itemId=" << viewId
|
|
<< "| itemType=" << viewType
|
|
<< "| collectionType=" << collectionType;
|
|
return;
|
|
}
|
|
|
|
for (MediaItem &cachedView : m_userViewsCache.views)
|
|
{
|
|
if (cachedView.id == viewId)
|
|
{
|
|
cachedView = std::move(view);
|
|
qDebug() << "[MediaService] Patched cached user view"
|
|
<< "| itemId=" << viewId
|
|
<< "| itemType=" << viewType
|
|
<< "| collectionType=" << collectionType
|
|
<< "| cachedCount=" << m_userViewsCache.views.size();
|
|
return;
|
|
}
|
|
}
|
|
|
|
qDebug() << "[MediaService] Skipping partial user view cache update"
|
|
<< "| reason=view-not-found"
|
|
<< "| itemId=" << viewId
|
|
<< "| itemType=" << viewType
|
|
<< "| collectionType=" << collectionType
|
|
<< "| cachedCount=" << m_userViewsCache.views.size();
|
|
}
|
|
|
|
|
|
|
|
|
|
QString RecommendCache::cacheFilePath(const QString &serverId)
|
|
{
|
|
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) +
|
|
QStringLiteral("/qEmby_RecommendCache_%1.json").arg(serverId);
|
|
}
|
|
|
|
void RecommendCache::clear()
|
|
{
|
|
QString cachedServerId = serverId;
|
|
items.clear();
|
|
fetchTime = QDateTime();
|
|
userId.clear();
|
|
serverId.clear();
|
|
requestLimit = 0;
|
|
if (!cachedServerId.isEmpty())
|
|
{
|
|
QFile::remove(cacheFilePath(cachedServerId));
|
|
}
|
|
}
|
|
|
|
void RecommendCache::saveToDisk() const
|
|
{
|
|
QJsonArray itemsArray;
|
|
for (const auto &item : items)
|
|
{
|
|
|
|
QJsonObject obj;
|
|
obj["Id"] = item.id;
|
|
obj["Name"] = item.name;
|
|
obj["Type"] = item.type;
|
|
obj["ProductionYear"] = item.productionYear;
|
|
obj["RecursiveItemCount"] = item.recursiveItemCount;
|
|
|
|
QJsonObject imgTags;
|
|
if (!item.images.primaryTag.isEmpty())
|
|
imgTags["Primary"] = item.images.primaryTag;
|
|
if (!item.images.thumbTag.isEmpty())
|
|
imgTags["Thumb"] = item.images.thumbTag;
|
|
if (!item.images.backdropTag.isEmpty())
|
|
imgTags["Backdrop"] = item.images.backdropTag;
|
|
if (!imgTags.isEmpty())
|
|
obj["ImageTags"] = imgTags;
|
|
if (item.images.primaryImageAspectRatio > 0)
|
|
obj["PrimaryImageAspectRatio"] = item.images.primaryImageAspectRatio;
|
|
itemsArray.append(obj);
|
|
}
|
|
|
|
QJsonObject root;
|
|
root["formatVersion"] = kRecommendCacheFormatVersion;
|
|
root["serverId"] = serverId;
|
|
root["userId"] = userId;
|
|
root["requestLimit"] = requestLimit;
|
|
root["fetchTime"] = fetchTime.toString(Qt::ISODate);
|
|
root["items"] = itemsArray;
|
|
|
|
QString filePath = cacheFilePath(serverId);
|
|
QDir().mkpath(QFileInfo(filePath).absolutePath());
|
|
QFile file(filePath);
|
|
if (file.open(QIODevice::WriteOnly))
|
|
{
|
|
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
|
|
qDebug() << "[RecommendCache] Saved" << items.size() << "items to disk for server" << serverId;
|
|
}
|
|
}
|
|
|
|
bool RecommendCache::loadFromDisk(const QString ¤tServerId, const QString ¤tUserId, int cacheDurationHours)
|
|
{
|
|
QFile file(cacheFilePath(currentServerId));
|
|
if (!file.open(QIODevice::ReadOnly))
|
|
return false;
|
|
|
|
QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
|
|
file.close();
|
|
|
|
const int formatVersion = root["formatVersion"].toInt();
|
|
if (formatVersion < kRecommendCacheFormatVersion)
|
|
{
|
|
qDebug() << "[RecommendCache] Disk cache format is outdated, ignoring "
|
|
"cache for server"
|
|
<< currentServerId << "| cachedVersion:" << formatVersion
|
|
<< "| requiredVersion:" << kRecommendCacheFormatVersion;
|
|
return false;
|
|
}
|
|
|
|
QString cachedServerId = root["serverId"].toString();
|
|
QString cachedUserId = root["userId"].toString();
|
|
const int cachedRequestLimit = root["requestLimit"].toInt(0);
|
|
QDateTime cachedTime = QDateTime::fromString(root["fetchTime"].toString(), Qt::ISODate);
|
|
|
|
|
|
if (cachedServerId != currentServerId)
|
|
return false;
|
|
if (cachedUserId != currentUserId)
|
|
return false;
|
|
if (cachedTime.secsTo(QDateTime::currentDateTime()) >= cacheDurationHours * 3600)
|
|
return false;
|
|
|
|
QJsonArray itemsArray = root["items"].toArray();
|
|
QList<MediaItem> loadedItems;
|
|
loadedItems.reserve(itemsArray.size());
|
|
for (const auto &val : itemsArray)
|
|
{
|
|
loadedItems.append(MediaItem::fromJson(val.toObject()));
|
|
}
|
|
|
|
|
|
items = loadedItems;
|
|
fetchTime = cachedTime;
|
|
serverId = cachedServerId;
|
|
userId = cachedUserId;
|
|
requestLimit = cachedRequestLimit;
|
|
qDebug() << "[RecommendCache] Loaded" << items.size() << "items from disk for server" << serverId;
|
|
return true;
|
|
}
|