#include "mediaservice.h" #include "../../api/apiclient.h" #include "../../config/config_keys.h" #include "../../config/configstore.h" #include "../manager/servermanager.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { template QList parseJsonArray(const QJsonArray &array) { QList 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(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& items) { QString fingerprint; for (const MediaItem& item : items) { if (item.id.isEmpty()) { continue; } fingerprint += item.id; fingerprint += QLatin1Char('|'); } return fingerprint; } QString findUserViewIdByCollectionType(const QList &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> 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 views = parseJsonArray(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> 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 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> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task 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(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 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 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 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> MediaService::fetchPagedItemList( QString basePath, int requestedLimit, QString context, bool deduplicateItems) { ensureValidProfile(); QList 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 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 items; if (deduplicateItems) { QSet 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> 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> 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 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 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> 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> { 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 entries; QHash seriesEntryIndexes; QHash 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 movies = co_await fetchPlayedItems(QStringLiteral("Movie"), queryLimit, requestSortBy, requestSortOrder); for (MediaItem movie : movies) { appendEntry(std::move(movie)); } const QList 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 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 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 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 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 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> 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(ConfigKeys::DataCacheDuration, "24").toInt(); if (cacheDurationHours <= 0) cacheDurationHours = 24; if (m_recommendCache.isValid(currentServerId, currentUserId, cacheDurationHours, limit)) { QList 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 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 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> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task 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 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 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 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 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> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task> 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(response["Items"].toArray()); } QCoro::Task 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> 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 parts; if (response.contains("Items")) { parts = parseJsonArray(response["Items"].toArray()); } else if (response.contains("data") && response["data"].isArray()) { parts = parseJsonArray(response["data"].toArray()); } co_return parts; } QCoro::Task> 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 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> 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 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 MediaService::reportPlaybackStart(QString itemId, QString mediaSourceId, long long positionTicks) { ensureValidProfile(); QPointer 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(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 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(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 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(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(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 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 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; }